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/build-linux.yml b/.woodpecker/build-linux.yml
index 736b01e89..802f8754d 100644
--- a/.woodpecker/build-linux.yml
+++ b/.woodpecker/build-linux.yml
@@ -14,16 +14,22 @@ steps:
   - export VERSION=$([ -z $CI_COMMIT_TAG ] && echo latest || echo $CI_COMMIT_TAG)-$(sed 's#/#-#g' <<< $CI_SYSTEM_PLATFORM)
   - echo $CONTAINER_PASSWORD | podman login $DRYCC_REGISTRY --username $CONTAINER_USERNAME --password-stdin > /dev/null 2>&1
   - make podman-build podman-immutable-push
-  secrets:
-  - codename
-  - dev_registry
-  - drycc_registry
-  - container_username
-  - container_password
+  environment:
+    CODENAME:
+      from_secret: codename
+    DEV_REGISTRY:
+      from_secret: dev_registry
+    DRYCC_REGISTRY:
+      from_secret: drycc_registry
+    CONTAINER_USERNAME:
+      from_secret: container_username
+    CONTAINER_PASSWORD:
+      from_secret: container_password
   when:
     event:
     - push
     - tag
+    - cron
 
 depends_on:
 - test-linux
\ No newline at end of file
diff --git a/.woodpecker/chart.yaml b/.woodpecker/chart.yaml
index 5aa7eb34e..63eb318c5 100644
--- a/.woodpecker/chart.yaml
+++ b/.woodpecker/chart.yaml
@@ -10,19 +10,25 @@ 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
   - helm push $${CI_REPO_NAME}-$CHART_VERSION.tgz oci://$DRYCC_REGISTRY/$([ -z $CI_COMMIT_TAG ] && echo charts-testing || echo charts)
-  secrets:
-  - dev_registry
-  - drycc_registry
-  - container_username
-  - container_password
+  environment:
+    DEV_REGISTRY:
+      from_secret: dev_registry
+    DRYCC_REGISTRY:
+      from_secret: drycc_registry
+    CONTAINER_USERNAME:
+      from_secret: container_username
+    CONTAINER_PASSWORD:
+      from_secret: container_password
   when:
     event:
     - push
     - tag
+    - cron
 
 depends_on:
 - manifest
diff --git a/.woodpecker/manifest.yml b/.woodpecker/manifest.yml
index 052f5d803..88ded6677 100644
--- a/.woodpecker/manifest.yml
+++ b/.woodpecker/manifest.yml
@@ -8,12 +8,14 @@ steps:
   commands:
   - sed -i "s/{{project}}/$${CI_REPO_NAME}/g" .woodpecker/manifest.tmpl
   - sed -i "s/registry.drycc.cc/$${DRYCC_REGISTRY}/g" .woodpecker/manifest.tmpl
-  secrets:
-  - drycc_registry
+  environment:
+    DRYCC_REGISTRY:
+      from_secret: drycc_registry
   when:
     event:
     - tag
     - push
+    - cron
 
 - name: publish-manifest
   image: bash
@@ -26,13 +28,16 @@ steps:
     -v $(pwd):$(pwd)
     -w $(pwd)
     plugins/manifest
-  secrets:
-  - container_username
-  - container_password
+  environment:
+    CONTAINER_USERNAME:
+      from_secret: container_username
+    CONTAINER_PASSWORD:
+      from_secret: container_password
   when:
     event:
     - tag
     - push
+    - cron
 
 depends_on:
 - build-linux
diff --git a/.woodpecker/test-linux.yml b/.woodpecker/test-linux.yml
index 373868083..f22a5f0fe 100644
--- a/.woodpecker/test-linux.yml
+++ b/.woodpecker/test-linux.yml
@@ -12,11 +12,15 @@ steps:
   image: bash
   commands:
   - make test podman-build-test upload-coverage
-  secrets:
-  - codename
-  - dev_registry
-  - codecov_token
+  environment:
+    CODENAME:
+      from_secret: codename
+    DEV_REGISTRY:
+      from_secret: dev_registry
+    CODECOV_TOKEN:
+      from_secret: codecov_token
   when:
     event:
     - push
     - tag
+    - cron
diff --git a/Makefile b/Makefile
index 62c6b6ac7..da92b03f1 100644
--- a/Makefile
+++ b/Makefile
@@ -63,6 +63,6 @@ test-integration:
 
 upload-coverage:
 	$(eval CI_ENV := $(shell curl -s https://codecov.io/env | bash))
-	podman run --rm ${CI_ENV} -v ${CURDIR}:/test -w /test/rootfs ${IMAGE}.test /test/rootfs/bin/upload-coverage
+	podman run --rm ${CI_ENV} -v ${CURDIR}:/test -w /test/rootfs -e CODECOV_TOKEN=${CODECOV_TOKEN} ${IMAGE}.test /test/rootfs/bin/upload-coverage
 
 .PHONY: check-kubectl check-podman build podman-build podman-build-test deploy clean commit-hook full-clean test test-style test-unit test-functional test-integration upload-coverage
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 a06afb3d8..80902a4d2 100644
--- a/charts/controller/templates/_helpers.tpl
+++ b/charts/controller/templates/_helpers.tpl
@@ -5,27 +5,24 @@ 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: "KUBERNETES_CLUSTER_DOMAIN"
-  value: "{{ .Values.global.clusterDomain }}"
+- name: "DRYCC_FILER_IMAGE"
+  value: "{{ (tpl .Values.filerImage .) }}"
+- name: "DRYCC_FILER_IMAGE_PULL_POLICY"
+  value: "{{ (tpl .Values.filerImagePullPolicy .) }}"
+- name: "DRYCC_APP_GATEWAY_CLASS"
+  value: "{{ .Values.appGatewayClass }}"
 {{- if (.Values.appStorageClass) }}
 - name: "DRYCC_APP_STORAGE_CLASS"
   value: "{{ (tpl .Values.appStorageClass .) }}"
 {{- end }}
-{{- if (.Values.appRuntimeClass) }}
-- name: "DRYCC_APP_RUNTIME_CLASS"
-  value: "{{ .Values.appRuntimeClass }}"
-{{- end }}
 {{- if (.Values.appDNSPolicy) }}
 - name: "DRYCC_APP_DNS_POLICY"
   value: "{{ .Values.appDNSPolicy }}"
@@ -52,11 +49,26 @@ env:
     secretKeyRef:
       name: controller-creds
       key: django-secret-key
-- name: DRYCC_BUILDER_KEY
+- name: DRYCC_SERVICE_KEY
+  valueFrom:
+    secretKeyRef:
+      name: controller-creds
+      key: service-key
+{{- if (.Values.valkeyUrl) }}
+- name: DRYCC_VALKEY_URL
+  valueFrom:
+    secretKeyRef:
+      name: controller-creds
+      key: valkey-url
+{{- else if .Values.valkey.enabled }}
+- name: VALKEY_PASSWORD
   valueFrom:
     secretKeyRef:
-      name: builder-key-auth
-      key: builder-key
+      name: valkey-creds
+      key: password
+- name: DRYCC_VALKEY_URL
+  value: "redis://:$(VALKEY_PASSWORD)@drycc-valkey:16379/0"
+{{- end }}
 {{- if (.Values.databaseReplicaUrl) }}
 - name: DRYCC_DATABASE_REPLICA_URL
   valueFrom:
@@ -70,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:
@@ -82,29 +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"
-{{- end }}
-{{- if (.Values.databaseMonitorUrl) }}
-- name: DRYCC_DATABASE_MONITOR_URL
-  valueFrom:
-    secretKeyRef:
-      name: controller-creds
-      key: database-monitor-url
-{{- else if eq .Values.global.timeseriesLocation "on-cluster"  }}
-- name: DRYCC_TS_USER
-  valueFrom:
-    secretKeyRef:
-      name: timeseries-creds
-      key: user
-- name: DRYCC_TS_PASSWORD
-  valueFrom:
-    secretKeyRef:
-      name: timeseries-creds
-      key: password
-- name: DRYCC_DATABASE_MONITOR_URL
-  value: "postgres://$(DRYCC_TS_USER):$(DRYCC_TS_PASSWORD)@drycc-timeseries-replica.{{.Release.Namespace}}.svc.{{.Values.global.clusterDomain}}:5432/monitor"
+  value: "postgres://$(DRYCC_PG_USER):$(DRYCC_PG_PASSWORD)@drycc-database-replica:5432/controller"
 {{- end }}
 {{- if (.Values.workflowManagerUrl) }}
 - name: WORKFLOW_MANAGER_URL
@@ -114,61 +106,29 @@ env:
 - name: WORKFLOW_MANAGER_SECRET_KEY
   value: "{{ .Values.workflowManagerSecretKey }}"
 {{- end }}
-- name: WORKFLOW_NAMESPACE
+- name: POD_IP
   valueFrom:
     fieldRef:
-      fieldPath: metadata.namespace
-- name: DRYCC_REDIS_ADDRS
-  valueFrom:
-    secretKeyRef:
-      name: redis-creds
-      key: addrs
-- name: DRYCC_REDIS_PASSWORD
-  valueFrom:
-    secretKeyRef:
-      name: redis-creds
-      key: password
-{{- if eq .Values.global.rabbitmqLocation "off-cluster" }}
-- name: "DRYCC_PROMETHEUS_URL"
-  valueFrom:
-    secretKeyRef:
-      name: prometheus-creds
-      key: url
-{{- else }}
-- name: "DRYCC_PROMETHEUS_USERNAME"
+      fieldPath: status.podIP
+- name: POD_NAME
   valueFrom:
-    secretKeyRef:
-      name: prometheus-creds
-      key: username
-- name: "DRYCC_PROMETHEUS_PASSWORD"
+    fieldRef:
+      fieldPath: metadata.name
+- name: WORKFLOW_NAMESPACE
   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"
-{{- end }}
-{{- if (.Values.rabbitmqUrl) }}
-- name: DRYCC_RABBITMQ_URL
+    fieldRef:
+      fieldPath: metadata.namespace
+{{- if (.Values.victoriametricsUrl) }}
+- name: "DRYCC_VICTORIAMETRICS_URL"
   valueFrom:
     secretKeyRef:
       name: controller-creds
-      key: rabbitmq-url
-{{- else if eq .Values.global.rabbitmqLocation "on-cluster" }}
-- name: "DRYCC_RABBITMQ_USERNAME"
-  valueFrom:
-    secretKeyRef:
-      name: rabbitmq-creds
-      key: username
-- name: "DRYCC_RABBITMQ_PASSWORD"
-  valueFrom:
-    secretKeyRef:
-      name: rabbitmq-creds
-      key: password
-- name: "DRYCC_RABBITMQ_URL"
-  value: "amqp://$(DRYCC_RABBITMQ_USERNAME):$(DRYCC_RABBITMQ_PASSWORD)@drycc-rabbitmq.{{$.Release.Namespace}}.svc.{{$.Values.global.clusterDomain}}:5672/drycc"
+      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 }}
@@ -202,160 +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 }}
 
-{{/* 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 }}
+{{- define "controller-job.envs" }}
+{{- include "controller.envs" . }}
+- name: DRYCC_DATABASE_ROUTERS
+  value: api.routers.DefaultReplicaRouter
 {{- 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" }}
+{{- define "controller-api.envs" }}
+{{- include "controller.envs" . }}
+- name: DRYCC_CONTROLLER_RUNNER
+  value: api
 {{- end }}
 
-{{/* Generate controller config default limit plans */}}
-{{ define "controller.config.defaultLimitPlans" }}
-{{- $index := 0 }}
-{{- $cpus := tuple 1 2 4 8 16 32 }}
-{{- $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: 2Gi
-    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: {}
-    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 }}
+{{- define "controller-metric.envs" }}
+{{- include "controller.envs" . }}
+- name: DRYCC_CONTROLLER_RUNNER
+  value: metric
 {{- end }}
 
-{{/* Generate controller config default metrics */}}
-{{ define "controller.config.defaultMetrics" }}
-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_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_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_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]
-{{- end }}
\ No newline at end of file
+{{- 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 75%
rename from charts/controller/templates/controller-api-deployment.yaml
rename to charts/controller/templates/api/controller-api-deployment.yaml
index 2c87eee85..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,29 +29,17 @@ 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:
         - netcat
         - -v
         - -u
-        - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL),$(DRYCC_RABBITMQ_URL)
-        - -a
-        - $(DRYCC_REDIS_ADDRS)
-        {{- include "controller.envs" . | indent 8 }}
-      - name: drycc-controller-waitting
-        image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
-        imagePullPolicy: {{.Values.imagePullPolicy}}
-        args:
-        - /usr/bin/env
-        - bash
-        - -ec
-        - |
-          python -u /workspace/manage.py cluster_lock waitting
-        {{- include "controller.envs" . | indent 8 }}
+        - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
+        {{- 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 }}
@@ -82,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 70%
rename from charts/controller/templates/controller-celery-deloyment.yaml
rename to charts/controller/templates/celery/controller-celery-deloyment.yaml
index cbe57dbcc..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_DATABASE_REPLICA_URL),$(DRYCC_RABBITMQ_URL)
-          - -a
-          - $(DRYCC_REDIS_ADDRS),$(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,7 +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
diff --git a/charts/controller/templates/controller-clusterrole.yaml b/charts/controller/templates/controller-clusterrole.yaml
index 5835b4d39..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:
@@ -16,7 +15,7 @@ rules:
 - apiGroups: [""]
   resources: ["nodes"]
   verbs: ["get", "list"]
-- apiGroups: [""]
+- apiGroups: ["events.k8s.io"]
   resources: ["events"]
   verbs: ["list", "create"]
 - apiGroups: [""]
@@ -52,24 +51,24 @@ 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"]
+- apiGroups: [""]
+  resources: ["persistentvolumes"]
+  verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
 - apiGroups: [""]
   resources: ["persistentvolumeclaims"]
   verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
@@ -80,10 +79,8 @@ rules:
   resources: ["serviceinstances", "servicebindings"]
   verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
 - apiGroups: ["gateway.networking.k8s.io"]
-  resources: ["gateways", "httproutes", "grocroutes", "tcproutes", "udproutes", "tlsroutes"]
+  resources: ["gateways", "httproutes", "grpcroutes", "tcproutes", "udproutes", "tlsroutes"]
   verbs: ["get", "patch", "list", "create", "update", "delete"]
 - 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 8b5cb5b35..18493da9a 100644
--- a/charts/controller/templates/controller-configmap.yaml
+++ b/charts/controller/templates/controller-configmap.yaml
@@ -9,17 +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 }}
+    {{- .Files.Get "files/limit-plans.json" | nindent 4 }}
+    {{- end }}
+  volume-template.json: |
+    {{- if .Values.config.volumeTemplate }}
+    {{- (tpl .Values.config.volumeTemplate $)  | nindent 4 }}
+    {{- else}}
+    {{- .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}}
+    {{-  .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 d97446e5e..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,10 +30,8 @@ spec:
               - netcat
               - -v
               - -u
-              - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL),$(DRYCC_RABBITMQ_URL)
-              - -a
-              - $(DRYCC_REDIS_ADDRS),$(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
-            {{- include "controller.envs" . | indent 12 }}
+              - $(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}}
@@ -47,6 +46,11 @@ spec:
             - python -u /workspace/manage.py clearsocial
             {{- 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
@@ -60,42 +64,12 @@ spec:
               - python /workspace/manage.py load_db_state_to_k8s
             {{- end }}
             {{- include "controller.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 }}
-          - 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 }}
-          - 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 94c73ff50..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
@@ -29,14 +30,68 @@ spec:
               - netcat
               - -v
               - -u
-              - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL),$(DRYCC_RABBITMQ_URL)
-              - -a
-              - $(DRYCC_REDIS_ADDRS),$(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
-            {{- include "controller.envs" . | indent 12 }}
+              - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
+              - -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,12 +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 }}
@@ -57,6 +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 }}
\ No newline at end of file
+            {{- 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-job-upgrade.yaml b/charts/controller/templates/controller-job-upgrade.yaml
index e37d59a16..133e534ad 100644
--- a/charts/controller/templates/controller-job-upgrade.yaml
+++ b/charts/controller/templates/controller-job-upgrade.yaml
@@ -1,17 +1,15 @@
 apiVersion: batch/v1
 kind: Job
 metadata:
-  name: drycc-controller-job-upgrade-{{ now | date "20060102150405" }}
-  labels:
-    heritage: drycc
+  name: drycc-controller-job-upgrade
   annotations:
     component.drycc.cc/version: {{ .Values.imageTag }}
+    helm.sh/hook: post-install,post-upgrade,post-rollback
+    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
 spec:
   template:
-    metadata:
-      labels: {{- include "common.labels.standard" . | nindent 8 }}
-        app: drycc-controller-job-upgrade
     spec:
+      serviceAccount: drycc-controller
       initContainers:
       - name: drycc-controller-job-upgrade-init
         image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/python-dev:latest
@@ -20,9 +18,7 @@ spec:
         - netcat
         - -v
         - -u
-        - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL),$(DRYCC_RABBITMQ_URL)
-        - -a
-        - $(DRYCC_REDIS_ADDRS)
+        - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
         {{- include "controller.envs" . | indent 8 }}
       containers:
       - name: drycc-controller-job-upgrade
@@ -37,11 +33,9 @@ spec:
         - bash
         - -ec
         - |
-          python -u /workspace/manage.py cluster_lock lock
           python -u /workspace/manage.py migrate --noinput
           python -u /workspace/manage.py loaddata /etc/controller/limit-specs.json
           python -u /workspace/manage.py loaddata /etc/controller/limit-plans.json
-          python -u /workspace/manage.py cluster_lock unlock
         {{- end }}
         {{- include "controller.envs" . | indent 8 }}
         volumeMounts:
@@ -53,5 +47,3 @@ spec:
         configMap:
           name: controller-config
       restartPolicy: Never
-  backoffLimit: 0
-  ttlSecondsAfterFinished: 3600
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 fdeb3acfd..0e78d51c6 100644
--- a/charts/controller/templates/controller-secret-creds.yaml
+++ b/charts/controller/templates/controller-secret-creds.yaml
@@ -5,17 +5,17 @@ metadata:
   labels:
     heritage: drycc
 data:
+  {{- if (.Values.valkeyUrl) }}
+  valkey-url: {{ .Values.valkeyUrl | b64enc }}
+  {{- end }}
   {{- if (.Values.databaseUrl) }}
   database-url: {{ .Values.databaseUrl | b64enc }}
   {{- end }}
   {{- if (.Values.databaseReplicaUrl) }}
   database-replica-url: {{ .Values.databaseReplicaUrl | b64enc }}
   {{- end }}
-  {{- if (.Values.databaseMonitorUrl) }}
-  database-monitor-url: {{ .Values.databaseMonitorUrl | b64enc }}
-  {{- end }}
-  {{- if (.Values.rabbitmqUrl) }}
-  rabbitmq-url: {{ .Values.rabbitmqUrl | 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 82%
rename from charts/controller/templates/controller-mutate-deloyment.yaml
rename to charts/controller/templates/mutate/controller-mutate-deloyment.yaml
index d30605770..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_DATABASE_REPLICA_URL),$(DRYCC_RABBITMQ_URL)
-        - -a
-        - $(DRYCC_REDIS_ADDRS),$(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 50%
rename from charts/controller/templates/controller-mutate-webhook.yaml
rename to charts/controller/templates/mutate/controller-mutate-webhook.yaml
index 637bd3ac5..bc7eb614b 100644
--- a/charts/controller/templates/controller-mutate-webhook.yaml
+++ b/charts/controller/templates/mutate/controller-mutate-webhook.yaml
@@ -7,16 +7,20 @@ apiVersion: admissionregistration.k8s.io/v1
 kind: MutatingWebhookConfiguration
 metadata:
   name: {{ .Release.Namespace }}-controller-mutate
+  annotations:
+    component.drycc.cc/version: {{ .Values.imageTag }}
+  labels:
+    heritage: drycc
 webhooks:
 - name: mutate.drycc.cc
   sideEffects: None
   admissionReviewVersions: ["v1"]
   clientConfig:
-    caBundle: {{ b64enc $ca.Cert }}
+    caBundle: {{ (include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "ca.crt" "defaultValue" $ca.Cert "context" $)) }}
     service:
       name: drycc-controller-mutate
       namespace: "{{ .Release.Namespace }}"
-      path: "{{ printf "/v2/mutate/%s/" $key }}"
+      path: "{{ printf "/v2/mutate/%s/" ((include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "key" "defaultValue" $key "context" $)) | b64dec) }}"
   failurePolicy: Fail
   objectSelector:
     matchLabels:
@@ -40,11 +44,14 @@ apiVersion: v1
 kind: Secret
 metadata:
   name: controller-mutate-cert
+  annotations:
+    component.drycc.cc/version: {{ .Values.imageTag }}
   labels:
-    release: "{{ .Release.Name }}"
-    heritage: "{{ .Release.Service }}"
+    heritage: drycc
 type: Opaque
 data:
-  key: {{ b64enc $key }}
-  tls.crt: {{ b64enc $cert.Cert }}
-  tls.key: {{ b64enc $cert.Key }}
+  key: {{ (include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "key" "defaultValue" $key "context" $)) }}
+  ca.key: {{ (include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "ca.key" "defaultValue" $ca.Key "context" $)) }}
+  ca.crt: {{ (include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "ca.crt" "defaultValue" $ca.Cert "context" $)) }}
+  tls.key: {{ (include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "tls.key" "defaultValue" $cert.Key "context" $)) }}
+  tls.crt: {{ (include "common.secrets.lookup" (dict "secret" "controller-mutate-cert" "key" "tls.crt" "defaultValue" $cert.Cert "context" $)) }}
diff --git a/charts/controller/values.yaml b/charts/controller/values.yaml
index 592465ebc..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
@@ -31,8 +29,13 @@ registrationMode: "admin_only"
 k8sApiVerifyTls: "true"
 # Set app image pull policy
 appImagePullPolicy: "Always"
-# Set storageClassName. It is used for application.
-appStorageClass: "{{ .Values.storage.csi.storageClassName }}"
+# Filer settings image and image pull policy
+filerImage: "registry.drycc.cc/drycc/filer:canary"
+filerImagePullPolicy: "Always"
+# 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.
@@ -40,17 +43,13 @@ 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
 # Replace: If it is time for a new job run and the previous job run hasn't finished yet, the cron job replaces the currently running job run with a new job run
 concurrencyPolicy: "Replace"
+## valkeyUrl is will no longer use the built-in valkey component
+valkeyUrl: ""
 # databaseUrl and databaseReplicaUrl are will no longer use the built-in database component
 databaseUrl: ""
 databaseReplicaUrl: ""
@@ -58,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: ""
-
-# Configuring this will no longer use the built-in rabbitmq component
-rabbitmqUrl: ""
-
+# 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:
@@ -80,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-monitor-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"
@@ -99,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"
@@ -113,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"
@@ -128,58 +180,30 @@ mutate:
     extraMatchLabels:
       component: "drycc-controller-mutate"
 
-redis:
-  replicas: 1
+valkey:
+  enabled: true
+
+database:
+  enabled: true
 
-storage:
-  csi:
-    storageClassName: drycc-storage
+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"
diff --git a/rootfs/Dockerfile b/rootfs/Dockerfile
index 4dbaea4f4..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.12"
+  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 1aa3698f6..74f4c8c91 100644
--- a/rootfs/Dockerfile.test
+++ b/rootfs/Dockerfile.test
@@ -1,23 +1,23 @@
 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.12" \
-  REDIS_VERSION="7.2.4" \
-  RABBITMQ_VERSION="3.12.13" \
-  POSTGRES_VERSION="15.5" \
-  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 \
+  && curl -SsL https://cli.codecov.io/latest/$([ $(dpkg --print-architecture) == "arm64" ] && echo linux-arm64 || echo linux)/codecov -o /usr/local/bin/codecov \
+  && chmod +x /usr/local/bin/codecov \
   && install-stack python $PYTHON_VERSION \
-  && install-stack redis $REDIS_VERSION \
-  && install-stack rabbitmq $RABBITMQ_VERSION \
+  && install-stack valkey $VALKEY_VERSION \
   && install-stack postgresql $POSTGRES_VERSION \
   && install-stack gosu $GOSU_VERSION && . init-stack \
   && python3 -m venv ${DRYCC_HOME_DIR}/.venv \
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/admissions.py b/rootfs/api/admissions.py
index b7c1e5a75..859f9b847 100644
--- a/rootfs/api/admissions.py
+++ b/rootfs/api/admissions.py
@@ -28,24 +28,29 @@ def detect(self, request: Request) -> bool:
     def handle(self, request: Request) -> bool:
         app_id = request["object"]["metadata"]["namespace"]
         app = models.app.App.objects.filter(id=app_id).first()
-        container_type = request["object"]["metadata"].get("labels", {}).get("type", "")
-        if app and container_type:
-            status = request["object"]["status"]
-            replicas = request["object"]["spec"].get("replicas", 0)
-            if "active" in status:
-                replicas += 1
-            elif "succeeded" in status or "failed" in status:
-                replicas -= 1
-            replicas = 0 if replicas < 0 else replicas
-            if app.structure.get(container_type, 0) != replicas:
-                models.app.App.objects.filter(id=app.id).update(
-                    structure=Func(
-                        F("structure"),
-                        Value([container_type]),
-                        Value(replicas, JSONField()),
-                        function="jsonb_set",
+        ptype = request["object"]["metadata"].get("labels", {}).get("type", "")
+        if app and ptype:
+            lock = app.lock()
+            try:
+                lock.acquire()
+                status = request["object"]["status"]
+                replicas = request["object"]["spec"].get("replicas", 0)
+                if "active" in status:
+                    replicas += 1
+                elif "succeeded" in status or "failed" in status:
+                    replicas -= 1
+                replicas = 0 if replicas < 0 else replicas
+                if app.structure.get(ptype, 0) != replicas:
+                    models.app.App.objects.filter(id=app.id).update(
+                        structure=Func(
+                            F("structure"),
+                            Value([ptype]),
+                            Value(replicas, JSONField()),
+                            function="jsonb_set",
+                        )
                     )
-                )
+            finally:
+                lock.release()
         return True
 
 
@@ -64,22 +69,27 @@ def detect(self, request: Request) -> bool:
     def handle(self, request: Request) -> bool:
         app_id = request["object"]["metadata"]["namespace"]
         app = models.app.App.objects.filter(id=app_id).first()
-        container_type = None
+        ptype = None
         for item in request["object"]["status"]["selector"].split(","):
             key, value = item.split("=")
             if key == "type":
-                container_type = value
-        if app and container_type:
-            replicas = request["object"]["spec"].get("replicas", 0)
-            if app.structure.get(container_type, 0) != replicas:
-                models.app.App.objects.filter(id=app.id).update(
-                    structure=Func(
-                        F("structure"),
-                        Value([container_type]),
-                        Value(replicas, JSONField()),
-                        function="jsonb_set",
+                ptype = value
+        if app and ptype:
+            lock = app.lock()
+            try:
+                lock.acquire()
+                replicas = request["object"]["spec"].get("replicas", 0)
+                if app.structure.get(ptype, 0) != replicas:
+                    models.app.App.objects.filter(id=app.id).update(
+                        structure=Func(
+                            F("structure"),
+                            Value([ptype]),
+                            Value(replicas, JSONField()),
+                            function="jsonb_set",
+                        )
                     )
-                )
+            finally:
+                lock.release()
         return True
 
 
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 68%
rename from rootfs/api/backend.py
rename to rootfs/api/apps_extra/social_core/backends.py
index 4b8864279..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,16 +25,14 @@ class DryccOIDC(OpenIdConnectAuth):
         ('scope', 'scope'),
     ]
 
-    @social_cache(ttl=86400)
-    def oidc_config(self):
-        return self.get_json(self.OIDC_ENDPOINT +
-                             '/.well-known/openid-configuration/')
-
     def get_user_data(self, access_token):
         """Loads user data from service"""
-        url = settings.SOCIAL_AUTH_DRYCC_USERINFO_URL
-        response = self.get_json(url, headers={
-            'authorization': 'Bearer ' + access_token})
+        response = self.get_json(
+            self.userinfo_url(),
+            headers={
+                'authorization': 'Bearer ' + access_token
+            },
+        )
         return {
             'id': response.get('id'),
             'username': response.get('username'),
@@ -54,12 +45,13 @@ 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,
             },
         )
@@ -67,8 +59,7 @@ def refresh_token(self, refresh_token):
 
 class OauthCacheManager(object):
 
-    def __init__(self, timeout=60 * 10):
-        self.timeout = timeout
+    def __init__(self):
         self.drycc_oauth = DryccOIDC()
 
     def get_user(self, access_token):
@@ -82,13 +73,13 @@ def _get_user(access_token):
                 logger.info(e)
                 raise exceptions.AuthenticationFailed(gettext_lazy('Verify token fail.'))
         return cache.get_or_set(
-            access_token, lambda: _get_user(access_token), settings.OAUTH_CACHE_USER_TIME)
+            access_token, lambda: _get_user(access_token), settings.DRYCC_CACHE_USER_TIME)
 
     def set_state(self, key, state):
-        cache.set("oidc_key_" + key, state, self.timeout)
+        cache.set("oidc_key_" + key, state, settings.DRYCC_CACHE_USER_TIME)
 
     def set_token(self, state, data):
-        cache.set("oidc_state_" + state, data, self.timeout)
+        cache.set("oidc_state_" + state, data, settings.DRYCC_CACHE_USER_TIME)
 
     def get_token(self, key):
         state = cache.get("oidc_key_" + key, "")
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 d423172a5..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(token, 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 10a3dadf8..47d680a83 100644
--- a/rootfs/api/consumers.py
+++ b/rootfs/api/consumers.py
@@ -4,45 +4,64 @@
 import ssl
 import aiohttp
 import asyncio
-import collections
+from collections import namedtuple
+from urllib.parse import urljoin
 from django.conf import settings
+from django.core.cache import cache
 
-from asgiref.sync import sync_to_async, async_to_sync
+from asgiref.sync import sync_to_async
 
+from urllib3.response import HTTPResponse
 from kubernetes.client import Configuration, exceptions
 from kubernetes.client.api import core_v1_api
 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
 
 
-Request = collections.namedtuple("Request", ["user", "method"])
+class AppPermChecker(object):
+    timeout = 60 * 60
+    check_permission = IsAppUser().has_object_permission
 
+    def __init__(self, scope):
+        self.scope = scope
 
-class BaseAppConsumer(AsyncWebsocketConsumer):
-
-    @database_sync_to_async
-    def has_perm(self):
+    async def has_perm(self):
         if self.scope["user"] is None:
             return False, "user not login"
-        request = Request(self.scope["user"], "POST")
-        try:
-            app = App.objects.get(id=self.id)
-            return has_app_permission(request, app)
-        except App.DoesNotExist:
-            return False, "user not exists"
+        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 = 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]:
+                    await cache.aset(key, permission, timeout=self.timeout)
+            except App.DoesNotExist:
+                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)
 
@@ -62,61 +81,76 @@ def kubernetes(self):
         return core_v1_api.CoreV1Api()
 
 
-class AppLogsConsumer(BaseAppConsumer):
-
-    async def receive(self, text_data=None, bytes_data=None):
-        if text_data is not None:
-            kwargs = json.loads(text_data)
-            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,
-            )
-            async with aiohttp.ClientSession() as session:
-                async with session.get(url) as response:
-                    async for data in response.content.iter_any():
-                        await self.send(text_data=data)
-            await self.close(code=1000)
-        else:
-            raise ValueError("text_data cannot be empty!")
-
-
 class AppPodLogsConsumer(BaseK8sConsumer):
 
     async def connect(self):
         await super().connect()
-        self.pod_id = self.scope["url_route"]["kwargs"]["pod_id"]
+        self.running = False
+        self.response = None
+        self.conneted = True
+        self.buffer = b''
+        self.delimiter = b"\r\n"
+        self.pod_name = self.scope["url_route"]["kwargs"]["name"]
 
-    @sync_to_async
-    def receive(self, text_data=None, bytes_data=None):
-        kwargs = json.loads(text_data)
+    def reader(self, sock):
+        self.buffer += sock.read()
         try:
-            stream = self.kubernetes.read_namespaced_pod_log(self.pod_id, self.id, **{
-                "tail_lines": kwargs.get("lines", 100),
-                "follow": kwargs.get("follow", False),
-                "container": kwargs.get("container", ""),
-                "_preload_content": False,
-            }).stream()
-            for line in stream:
-                async_to_sync(self.send)(text_data=line)
-        except exceptions.ApiException as e:
-            async_to_sync(self.send)(text_data=str(e))
-        async_to_sync(self.close)(code=1000)
+            while self.buffer and self.buffer.endswith(self.delimiter):
+                index = self.buffer.index(self.delimiter)
+                length = int(self.buffer[:index], base=16)
+                if length == 0:
+                    asyncio.create_task(self.close(code=1000))
+                    break
+                start_pos = index + len(self.delimiter)
+                end_pos = start_pos + length + len(self.delimiter)
+                asyncio.create_task(
+                    self.send(bytes_data=self.buffer[start_pos:end_pos].strip(self.delimiter)))
+                self.buffer = self.buffer[end_pos:]
+        except BaseException:
+            asyncio.create_task(self.close(code=1000))
+
+    async def receive(self, text_data=None, bytes_data=None):
+        if self.running:
+            return
+        self.running = True
+        data = json.loads(text_data)
+        args = (self.pod_name, self.id)
+        lines = data.get("lines", 300)
+        follow = data.get("follow", False)
+        previous = data.get("previous", False)
+        kwargs = {
+            "follow": follow,
+            "container": data.get("container", ""),
+            "_preload_content": not follow,
+            "previous": previous
+        }
+        if lines > 0:
+            kwargs["tail_lines"] = lines
+        self.response = await sync_to_async(self.kubernetes.read_namespaced_pod_log)(
+            *args, **kwargs)
+        if follow:
+            loop = asyncio.get_event_loop()
+            loop.add_reader(
+                self.response.connection.sock, self.reader, self.response.connection.sock)
+        else:
+            asyncio.create_task(self.send(text_data=self.response))
+            asyncio.create_task(self.close(code=1000))
+
+    async def disconnect(self, close_code):
+        if isinstance(self.response, HTTPResponse):
+            loop = asyncio.get_event_loop()
+            loop.remove_reader(self.response.connection.sock)
+            await sync_to_async(self.response.close)()
+        self.conneted = False
 
 
 class AppPodExecConsumer(BaseK8sConsumer):
 
     async def connect(self):
+        await super().connect()
         self.stream = None
         self.conneted = True
-        await super().connect()
-        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)
@@ -128,39 +162,138 @@ async def send(self, data, channel=STDOUT_CHANNEL):
         elif isinstance(data, str):
             await super().send(text_data=channel_prefix+data)
 
+    async def wait(self):
+        future, loop = asyncio.Future(), asyncio.get_event_loop()
+        loop.add_reader(self.stream.sock, future.set_result, None)
+        future.add_done_callback(lambda f: loop.remove_reader(self.stream.sock))
+        await future
+
     async def task(self):
-        deadline = time.time() + settings.DRYCC_APP_POD_EXEC_TIMEOUT
-        while self.stream.is_open() and self.conneted and time.time() < deadline:
-            try:
-                await sync_to_async(self.stream.update)(0.1)
-                for channel in (ERROR_CHANNEL, STDOUT_CHANNEL, STDERR_CHANNEL):
-                    if channel in self.stream._channels:
-                        data = self.stream.read_channel(channel)
-                        await self.send(data, channel)
-            except ssl.SSLEOFError:
-                break
-        await self.close(code=1000)
+        try:
+            deadline = time.time() + settings.DRYCC_APP_POD_EXEC_TIMEOUT
+            while self.stream.is_open() and self.conneted and time.time() < deadline:
+                try:
+                    await self.wait()
+                    self.stream.update()
+                    for channel in (ERROR_CHANNEL, STDOUT_CHANNEL, STDERR_CHANNEL):
+                        if channel in self.stream._channels:
+                            data = self.stream.read_channel(channel)
+                            await self.send(data, channel)
+                except ssl.SSLEOFError:
+                    break
+        except exceptions.ApiException as e:
+            await self.send(str(e), STDERR_CHANNEL)
+        finally:
+            await self.close(code=1000)
 
     async def disconnect(self, close_code):
         if self.stream:
-            self.stream.close()
+            await sync_to_async(self.stream.close)()
         self.conneted = False
 
     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})
-            try:
-                self.stream = stream(*args, **kwargs)
-            except exceptions.ApiException as e:
-                await self.send(str(e), STDERR_CHANNEL)
-                await self.close(code=1000)
-            else:
-                asyncio.create_task(self.task())
+            self.stream = await sync_to_async(stream)(*args, **kwargs)
+            asyncio.create_task(self.task())
         elif self.stream is not None:
             data = text_data if text_data else bytes_data
             channel, data = ord(data[0]), data[1:]
             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
new file mode 100644
index 000000000..5e2d092da
--- /dev/null
+++ b/rootfs/api/filer.py
@@ -0,0 +1,111 @@
+import uuid
+import logging
+import asyncio
+import aiohttp
+from django.conf import settings
+from django.core.cache import cache
+from asgiref.sync import sync_to_async
+
+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, url_path):
+        self.path = "/data"
+        self.ports = (9000, 9100)
+        self.app_id = app_id
+        self.volume = volume
+        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)
+
+    async def bind(self):
+        lock_key = f"filer:lock:{self.app_id}:{self.volume.name}"
+        lock = CacheLock(lock_key)
+        try:
+            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:
+            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):
+        return f"filer:{self.app_id}:{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)
+        self.scheduler.job.create(self.app_id, job_name, settings.DRYCC_FILER_IMAGE, **{
+            "command": ["init-stack", "/usr/bin/pingguard"],
+            "args": [
+                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}",
+            ],
+            "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,
+        })
+        return job_name
+
+    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"]
+
+    async def _check_health(self, filer):
+        try:
+            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
diff --git a/rootfs/api/files/__init__.py b/rootfs/api/files/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rootfs/api/files/parsers.py b/rootfs/api/files/parsers.py
new file mode 100644
index 000000000..867b943c3
--- /dev/null
+++ b/rootfs/api/files/parsers.py
@@ -0,0 +1,58 @@
+import contextlib
+from django.utils.http import parse_header_parameters
+from django.core.files.uploadedfile import UploadedFile
+from rest_framework.exceptions import ParseError
+from rest_framework.parsers import FileUploadParser, DataAndFiles
+
+
+class FilerFile(UploadedFile):
+
+    def __init__(self, filename, filepath, input_data, content_length):
+        self.filename = filename
+        self.filepath = filepath
+        super().__init__(input_data, name=filename, size=content_length)
+
+
+class FilerUploadParser(FileUploadParser):
+    """
+    Filer upload parser.
+    """
+    media_type = 'filer/octet-stream'
+
+    errors = {
+        "no_filepath": (
+            "Missing filepath. Request should include a Content-Disposition "
+            "header with a filepath parameter."
+        ),
+    }
+    errors.update(FileUploadParser.errors)
+
+    def parse(self, stream, media_type=None, parser_context=None):
+        request = parser_context['request']
+        filename = self.get_filename(stream, media_type, parser_context)
+        if not filename:
+            raise ParseError(self.errors['no_filename'])
+        filepath = self.get_filepath(stream, media_type, parser_context)
+        if not filepath:
+            raise ParseError(self.errors['no_filepath'])
+        try:
+            content_length = int(request.META.get('HTTP_CONTENT_LENGTH',
+                                                  request.META.get('CONTENT_LENGTH', 0)))
+        except (ValueError, TypeError):
+            content_length = None
+        return DataAndFiles({}, {'file': FilerFile(filename, filepath, stream, content_length)})
+
+    def get_filepath(self, stream, media_type, parser_context):
+        """
+        Detects the uploaded file name. First searches a 'filepath' url kwarg.
+        Then tries to parse Content-Disposition header.
+        """
+        with contextlib.suppress(KeyError):
+            return parser_context['kwargs']['filepath']
+
+        with contextlib.suppress(AttributeError, KeyError, ValueError):
+            meta = parser_context['request'].META
+            _, params = parse_header_parameters(meta['HTTP_CONTENT_DISPOSITION'])
+            if 'filepath*' in params:
+                return params['filepath*']
+            return params['filepath']
diff --git a/rootfs/api/management/commands/cluster_lock.py b/rootfs/api/management/commands/cluster_lock.py
deleted file mode 100644
index cd0560846..000000000
--- a/rootfs/api/management/commands/cluster_lock.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import time
-from django.core.management.base import BaseCommand
-from django.core.cache import cache
-from django.conf import settings
-
-lock_key = "drycc:controller:version"
-waitting_init_msg = "version inconsistency %s!=%s, waiting for initialization to complete..."
-
-
-class Command(BaseCommand):
-    """Management command for push data to manager"""
-
-    def add_arguments(self, parser):
-        parser.add_argument(
-            "args",
-            metavar="action",
-            nargs="+",
-            choices=["lock", "unlock", "waitting"],
-            help="an action that needs to be executed.",
-        )
-
-    def lock(self):
-        cache.delete(lock_key)
-        print("lock completed!")
-
-    def unlock(self):
-        cache.set(lock_key, settings.VERSION, timeout=None)
-        print("unlock completed!")
-
-    def waitting(self):
-        while True:
-            version = cache.get(lock_key, None)
-            if version != settings.VERSION:
-                print(waitting_init_msg % (version, settings.VERSION))
-            else:
-                break
-            time.sleep(10)
-        print("waiting completed!")
-
-    def handle(self, *args, **options):
-        action = args[0]
-        getattr(self, action)()
diff --git a/rootfs/api/management/commands/load_db_state_to_k8s.py b/rootfs/api/management/commands/load_db_state_to_k8s.py
index cb0cf384f..ebce06e61 100644
--- a/rootfs/api/management/commands/load_db_state_to_k8s.py
+++ b/rootfs/api/management/commands/load_db_state_to_k8s.py
@@ -20,14 +20,9 @@ class Command(BaseCommand):
     to k8s.
     """
 
-    def _deploy(self, app, release):
-        if release.build is None:
-            print('WARNING: {} has no build associated with '
-                  'its latest release. Skipping deployment...'.format(app))
-            return
-
+    def _deploy(self, app):
         try:
-            app.deploy(release)
+            app.state_to_k8s()
         except AlreadyExists as error:
             logger.debug(error)
             print('WARNING: {} has a deployment in progress. '
@@ -50,13 +45,9 @@ def handle(self, *args, **options):
                 cert.attach_in_kubernetes(domain)
 
         # deploy apps
-        print("Deploying available applications")
+        print("Deploying available applications.")
         for app in App.objects.all():
-            release = app.release_set.filter(failed=False).latest()
-            if release.canary:
-                self._deploy(app, app.release_set.filter(failed=False, canary=False).latest())
-            self._deploy(app, release)
-
+            self._deploy(app)
         print("Done Publishing DB state to kubernetes.")
 
     def save_apps(self):
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 68f5fa83d..000000000
--- a/rootfs/api/management/commands/measure_loadbalancers.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import uuid
-import time
-import logging
-import ipaddress
-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 item in monitor.query_loadbalancer(app_map.keys(), start, stop):
-            ip = item["ip"]
-            namespace = item["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": 1,
-                "kwargs": {
-                    "ip": ip,
-                    "service": item["service"],
-                    "hostname": item["hostname"],
-                },
-                "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 559452377..000000000
--- a/rootfs/api/management/commands/measure_networks.py
+++ /dev/null
@@ -1,52 +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 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 namespace, pod_name, rx_bytes, tx_bytes in monitor.query_network_flow(
-                app_map.keys(), start, stop):
-            owner_id = app_map[namespace].owner_id
-            base_measure = {
-                "app_id":  str(app_map[namespace].uuid),
-                "owner": owner_id,
-                "type": "network",
-                "unit": "bytes",
-                "kwargs": {
-                    "pod": pod_name,
-                },
-                "timestamp": start
-            }
-            networks.append(base_measure | {"name": "rx", "usage": rx_bytes})
-            networks.append(base_measure | {"name": "tx", "usage": tx_bytes})
-        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 18a7b2ebb..53b22eb7f 100644
--- a/rootfs/api/manager.py
+++ b/rootfs/api/manager.py
@@ -1,14 +1,19 @@
 import base64
+import logging
 import requests
 from typing import List, Dict
+from django.core.cache import cache
 from requests_toolbelt import user_agent
 from django.conf import settings
 from api import __version__ as drycc_version
 
+logger = logging.getLogger(__name__)
+
 
 class ManagerAPI(object):
 
-    def __init__(self):
+    def __init__(self, timeout=3):
+        self.timeout = timeout
         token = base64.b85encode(b"%s:%s" % (
             settings.WORKFLOW_MANAGER_ACCESS_KEY.encode("utf8"),
             settings.WORKFLOW_MANAGER_SECRET_KEY.encode("utf8"),
@@ -23,6 +28,7 @@ def request(self, method, url, **kwargs):
         headers = kwargs.get("headers", {})
         headers.update(self.headers)
         kwargs["headers"] = headers
+        kwargs["timeout"] = self.timeout
         return requests.request(method, url, **kwargs)
 
     def get(self, url, params=None, **kwargs):
@@ -48,34 +54,49 @@ 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"
         }
         """
-        url = f"{settings.WORKFLOW_MANAGER_URL}/users/{id}/status/"
-        return self.get(url=url).json()
+        key = f"workspace:status:{workspace_id}"
+        status = cache.get(key)
+        if not 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 workspace {workspace_id} timeout, skipping verification."
+                status = {"is_active": True, "message": msg}
+                logger.error(msg)
+                logger.exception(ex)
+            cache.set(key, status, timeout=settings.DRYCC_CACHE_USER_TIME)
+        return status
+
+
+class UserAPI(WorkspaceAPI):
+    """Backward-compatible alias for legacy call sites."""
 
 
-class Measurement(ManagerAPI):
+class UsageAPI(ManagerAPI):
 
-    def post(self, measurements: List[Dict[str, str]]):
+    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 651f60982..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/
 """
-import collections
-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,25 +35,48 @@ 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.
     """
-    Request = collections.namedtuple('Request', ["META"])
+    authentication = DryccAuthentication()
 
-    async def get_user(self, scope):
+    @database_sync_to_async
+    def login(self, scope):
         headers = {}
         for header in scope["headers"]:
-            if header[0] in (b"user-agent", b"authorization"):
+            if header[0] in (b"authorization", ):
                 key = "HTTP_%s" % header[0].decode().replace("-", "_").upper()
                 headers[key] = header[1].decode()
-        if len(headers) != 2:
+        if len(headers) < 1:
             return None
-        request = self.Request(headers)
-        user, _ = await database_sync_to_async(DryccAuthentication().authenticate)(request)
-        return user
+        request = HttpRequest()
+        request.META = headers
+        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/0008_alter_domain_procfile_type_alter_route_procfile_type_and_more.py b/rootfs/api/migrations/0008_alter_domain_procfile_type_alter_route_procfile_type_and_more.py
new file mode 100644
index 000000000..6497eec76
--- /dev/null
+++ b/rootfs/api/migrations/0008_alter_domain_procfile_type_alter_route_procfile_type_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.10 on 2024-05-15 04:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0007_domain_procfile_type'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='domain',
+            name='procfile_type',
+            field=models.CharField(max_length=63),
+        ),
+        migrations.AlterField(
+            model_name='route',
+            name='procfile_type',
+            field=models.CharField(max_length=63),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='procfile_type',
+            field=models.CharField(max_length=63),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0009_remove_appsettings_canaries_remove_release_canary_and_more.py b/rootfs/api/migrations/0009_remove_appsettings_canaries_remove_release_canary_and_more.py
new file mode 100644
index 000000000..563a1e0d1
--- /dev/null
+++ b/rootfs/api/migrations/0009_remove_appsettings_canaries_remove_release_canary_and_more.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.2.11 on 2024-07-01 07:58
+
+from django.db import migrations
+
+
+def migration_route_rules(apps, schema_editor):
+    route_model = apps.get_model('api', 'Route')
+    for route in route_model.objects.all():
+        if isinstance(route.rules, dict):
+            route.rules = route.rules.get("stable")
+        route.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0008_alter_domain_procfile_type_alter_route_procfile_type_and_more'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='appsettings',
+            name='canaries',
+        ),
+        migrations.RemoveField(
+            model_name='release',
+            name='canary',
+        ),
+        migrations.RemoveField(
+            model_name='service',
+            name='canary',
+        ),
+        migrations.RunPython(migration_route_rules),
+    ]
diff --git a/rootfs/api/migrations/0010_appsettings_autorollback.py b/rootfs/api/migrations/0010_appsettings_autorollback.py
new file mode 100644
index 000000000..aa4ab8f88
--- /dev/null
+++ b/rootfs/api/migrations/0010_appsettings_autorollback.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.15 on 2024-08-13 07:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0009_remove_appsettings_canaries_remove_release_canary_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='appsettings',
+            name='autorollback',
+            field=models.BooleanField(default=True),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0011_appsettings_autodeploy.py b/rootfs/api/migrations/0011_appsettings_autodeploy.py
new file mode 100644
index 000000000..5744324da
--- /dev/null
+++ b/rootfs/api/migrations/0011_appsettings_autodeploy.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.15 on 2024-08-27 02:18
+
+from django.db import migrations, models
+
+
+def migration_dryccfile(apps, schema_editor):
+    build_model = apps.get_model('api', 'Build')
+    for build in build_model.objects.all():
+        if build.dryccfile and isinstance(build.dryccfile.get('run'), dict):
+            build.dryccfile['run'] = [build.dryccfile['run']]
+            build.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0010_appsettings_autorollback'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='appsettings',
+            name='autodeploy',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.RunPython(migration_dryccfile),
+    ]
diff --git a/rootfs/api/migrations/0012_alter_app_options_rename_procfile_type_domain_ptype_and_more.py b/rootfs/api/migrations/0012_alter_app_options_rename_procfile_type_domain_ptype_and_more.py
new file mode 100644
index 000000000..8214d715b
--- /dev/null
+++ b/rootfs/api/migrations/0012_alter_app_options_rename_procfile_type_domain_ptype_and_more.py
@@ -0,0 +1,57 @@
+# Generated by Django 4.2.15 on 2024-09-06 03:57
+
+import api.utils
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0011_appsettings_autodeploy'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='app',
+            options={'ordering': ['id'], 'verbose_name': 'Application'},
+        ),
+        migrations.RenameField(
+            model_name='domain',
+            old_name='procfile_type',
+            new_name='ptype',
+        ),
+        migrations.RenameField(
+            model_name='route',
+            old_name='procfile_type',
+            new_name='ptype',
+        ),
+        migrations.RenameField(
+            model_name='service',
+            old_name='procfile_type',
+            new_name='ptype',
+        ),
+        migrations.AddField(
+            model_name='certificate',
+            name='app',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.app'),
+        ),
+        migrations.AddField(
+            model_name='release',
+            name='conditions',
+            field=models.JSONField(default=list),
+        ),
+        migrations.AlterField(
+            model_name='certificate',
+            name='name',
+            field=models.CharField(max_length=253, validators=[api.utils.validate_label]),
+        ),
+        migrations.AlterUniqueTogether(
+            name='certificate',
+            unique_together={('app', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='service',
+            unique_together={('app', 'ptype')},
+        ),
+    ]
diff --git a/rootfs/api/migrations/0013_migration_permissions_and_certificates.py b/rootfs/api/migrations/0013_migration_permissions_and_certificates.py
new file mode 100644
index 000000000..63e87c4ab
--- /dev/null
+++ b/rootfs/api/migrations/0013_migration_permissions_and_certificates.py
@@ -0,0 +1,31 @@
+# Generated by Django 4.2.15 on 2024-09-03 03:48
+
+from django.db import migrations
+from api.models.domain import Domain
+from api.models.certificate import Certificate
+
+
+def migration_certificate(apps, schema_editor):
+    for domain in Domain.objects.all():
+        if domain.certificate:
+            certificate = Certificate.objects.filter(
+                app=domain.app, name=domain.certificate.name).first()
+            if not certificate:
+                certificate = domain.certificate
+                certificate.pk = None
+                certificate.app = domain.app
+                certificate.save()
+            domain.certificate = certificate
+            domain.save()
+    Certificate.objects.filter(app=None).delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0012_alter_app_options_rename_procfile_type_domain_ptype_and_more'),
+    ]
+
+    operations = [
+        migrations.RunPython(migration_certificate),
+    ]
diff --git a/rootfs/api/migrations/0014_alter_certificate_app.py b/rootfs/api/migrations/0014_alter_certificate_app.py
new file mode 100644
index 000000000..8d536fd7e
--- /dev/null
+++ b/rootfs/api/migrations/0014_alter_certificate_app.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.15 on 2024-09-06 03:58
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0013_migration_permissions_and_certificates'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='certificate',
+            name='app',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.app'),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0015_alter_appsettings_autodeploy_and_more.py b/rootfs/api/migrations/0015_alter_appsettings_autodeploy_and_more.py
new file mode 100644
index 000000000..f3786e0a8
--- /dev/null
+++ b/rootfs/api/migrations/0015_alter_appsettings_autodeploy_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.15 on 2024-09-24 09:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0014_alter_certificate_app'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='appsettings',
+            name='autodeploy',
+            field=models.BooleanField(default=None),
+        ),
+        migrations.AlterField(
+            model_name='appsettings',
+            name='autorollback',
+            field=models.BooleanField(default=None),
+        ),
+        migrations.AlterField(
+            model_name='appsettings',
+            name='routable',
+            field=models.BooleanField(default=None),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0016_remove_route_port_remove_route_ptype_and_more.py b/rootfs/api/migrations/0016_remove_route_port_remove_route_ptype_and_more.py
new file mode 100644
index 000000000..8f5f29df2
--- /dev/null
+++ b/rootfs/api/migrations/0016_remove_route_port_remove_route_ptype_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.16 on 2024-10-21 04:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0015_alter_appsettings_autodeploy_and_more'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='route',
+            name='port',
+        ),
+        migrations.RemoveField(
+            model_name='route',
+            name='ptype',
+        ),
+        migrations.AddField(
+            model_name='release',
+            name='deployed_ptypes',
+            field=models.JSONField(default=list),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0017_config_values_refs.py b/rootfs/api/migrations/0017_config_values_refs.py
new file mode 100644
index 000000000..f1538341b
--- /dev/null
+++ b/rootfs/api/migrations/0017_config_values_refs.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.16 on 2024-11-12 02:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0016_remove_route_port_remove_route_ptype_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='config',
+            name='values_refs',
+            field=models.JSONField(blank=True, default=dict),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0018_migration_config_values_and_typed_values.py b/rootfs/api/migrations/0018_migration_config_values_and_typed_values.py
new file mode 100644
index 000000000..b269d1844
--- /dev/null
+++ b/rootfs/api/migrations/0018_migration_config_values_and_typed_values.py
@@ -0,0 +1,43 @@
+# Generated by Django 4.2.15 on 2024-09-06 03:58
+import json
+from django.db import connection, migrations
+from api.models.config import Config
+
+
+def migration_values(apps, schema_editor):
+    with connection.cursor() as cursor:
+        cursor.execute("SELECT uuid,values,typed_values,registry FROM api_config")
+        for uuid, values, typed_values, registry in cursor:
+            new_values = []
+            config = Config.objects.get(pk=uuid)
+            for name, value in json.loads(values).items():
+                new_values.append({
+                    "name": name,
+                    "group": "global",
+                    "value": value
+                })
+            for ptype, values in json.loads(typed_values).items():
+                for name, value in values.items():
+                    new_values.append({
+                        "name": name,
+                        "ptype": ptype,
+                        "value": value
+                    })
+            new_registry, data = {}, json.loads(registry)
+            if data:
+                for ptype in config.app.structure.keys():
+                    new_registry[ptype] = data
+            config.values = new_values
+            config.registry = new_registry
+            config.save(ignore_update_fields=config.allof_fields)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0017_config_values_refs'),
+    ]
+
+    operations = [
+        migrations.RunPython(migration_values),
+    ]
diff --git a/rootfs/api/migrations/0019_remove_config_typed_values_alter_config_values.py b/rootfs/api/migrations/0019_remove_config_typed_values_alter_config_values.py
new file mode 100644
index 000000000..ee1a0b9fe
--- /dev/null
+++ b/rootfs/api/migrations/0019_remove_config_typed_values_alter_config_values.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.16 on 2024-11-06 06:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0018_migration_config_values_and_typed_values'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='config',
+            name='typed_values',
+        ),
+        migrations.AlterField(
+            model_name='config',
+            name='values',
+            field=models.JSONField(blank=True, default=list),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0020_limitplan_runtime_class_name.py b/rootfs/api/migrations/0020_limitplan_runtime_class_name.py
new file mode 100644
index 000000000..c473af4e0
--- /dev/null
+++ b/rootfs/api/migrations/0020_limitplan_runtime_class_name.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.17 on 2024-12-25 08:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0019_remove_config_typed_values_alter_config_values'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='limitplan',
+            name='runtime_class_name',
+            field=models.CharField(default='', max_length=63),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0021_migration_dryccfile.py b/rootfs/api/migrations/0021_migration_dryccfile.py
new file mode 100644
index 000000000..3bd0140ba
--- /dev/null
+++ b/rootfs/api/migrations/0021_migration_dryccfile.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.2.15 on 2024-09-06 03:58
+
+from django.db import migrations
+from api.models.release import Release
+
+
+def migration_dryccfile(apps, schema_editor):
+    for release in Release.objects.all():
+        build = release.build
+        if not build or not build.dryccfile or "pipeline" in build.dryccfile:
+            continue
+        pipeline = {}
+        for key, value in build.dryccfile.get('deploy', {}).items():
+            pipeline.update({
+                "%s.yaml" % key: {
+                    "kind": "pipeline",
+                    "ptype": key,
+                    "deploy": value,
+                }
+            })
+        build.dryccfile = {"pipeline": pipeline}
+        build.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0020_limitplan_runtime_class_name'),
+    ]
+
+    operations = [
+        migrations.RunPython(migration_dryccfile),
+    ]
diff --git a/rootfs/api/migrations/0022_migration_deployed_ptypes.py b/rootfs/api/migrations/0022_migration_deployed_ptypes.py
new file mode 100644
index 000000000..b5a9768ac
--- /dev/null
+++ b/rootfs/api/migrations/0022_migration_deployed_ptypes.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.15 on 2024-09-06 03:58
+
+from django.db import migrations
+from api.models.release import Release
+
+
+def migration_deployed_ptypes(apps, schema_editor):
+    for release in Release.objects.all():
+        deployed_ptypes = set()
+        for condition in release.conditions:
+            if condition["ptypes"] is None:
+                deployed_ptypes = release.ptypes
+                break
+            else:
+                deployed_ptypes = deployed_ptypes.union(condition["ptypes"])
+        release.deployed_ptypes = list(deployed_ptypes)
+        release.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0021_migration_dryccfile'),
+    ]
+
+    operations = [
+        migrations.RunPython(migration_deployed_ptypes),
+    ]
\ No newline at end of file
diff --git a/rootfs/api/migrations/0023_migration_k8s_alter_volume_type.py b/rootfs/api/migrations/0023_migration_k8s_alter_volume_type.py
new file mode 100644
index 000000000..0adac79fa
--- /dev/null
+++ b/rootfs/api/migrations/0023_migration_k8s_alter_volume_type.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.18 on 2025-03-26 02:44
+
+from django.db import migrations, models
+from api.models.volume import Volume
+
+
+def save_volume_to_k8s(apps, schema_editor):
+    for volume in Volume.objects.all():
+        if volume.type == 'nfs':
+            volume.save_to_k8s()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0022_migration_deployed_ptypes'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='volume',
+            name='type',
+            field=models.CharField(choices=[('csi', 'container storage interface'), ('nfs', 'network file system'), ('oss', 'object storage service file')], default='csi'),
+        ),
+        migrations.RunPython(save_volume_to_k8s),
+    ]
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 dcbe069a1..3e7914035 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -1,4 +1,3 @@
-import backoff
 import base64
 import functools
 import json
@@ -17,15 +16,17 @@
 from docker import auth as docker_auth
 from django.conf import settings
 from django.db import models
+from django.db.models import F, Func, Value, JSONField
 from django.contrib.auth import get_user_model
-from rest_framework.exceptions import ValidationError, NotFound
+from rest_framework.exceptions import ValidationError
 
-from api.utils import get_session
+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 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 scheduler.resources.pod import DEFAULT_CONTAINER_PORT
-from .gateway import Gateway, Route, DEFAULT_HTTP_PORT
+from .gateway import Gateway, Route
 from .limit import LimitPlan
 from .config import Config
 from .service import Service
@@ -33,12 +34,10 @@
 from .tls import TLS
 from .appsettings import AppSettings
 from .volume import Volume
-from .base import UuidAuditedModel
+from .base import UuidAuditedModel, PTYPE_WEB, PTYPE_RUN, DEFAULT_HTTP_PORT
 
 User = get_user_model()
 logger = logging.getLogger(__name__)
-PROCFILE_TYPE_WEB = "web"
-PROCFILE_TYPE_RUN = "run"
 
 
 # http://kubernetes.io/v1.1/docs/design/identifiers.html
@@ -46,42 +45,38 @@ 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])?(?<!-canary)$', 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'
-        permissions = (('use_app', 'Can use app'),)
         ordering = ['id']
 
     def save(self, *args, **kwargs):
@@ -96,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)
@@ -111,10 +106,44 @@ def save(self, *args, **kwargs):
 
         return application
 
+    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 procfile_types(self):
+    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
+        """
+        if not ptypes:
+            ptypes = self.ptypes
+        else:
+            invalid_ptypes = ptypes.difference(self.ptypes)
+            if len(invalid_ptypes) != 0:
+                raise DryccException("process type {} is not included in procfile".
+                                     format(','.join(invalid_ptypes)))
+        return ptypes
+
     def log(self, message, level=logging.INFO):
         """Logs a message in the context of this application.
 
@@ -124,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
@@ -137,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
             )
 
@@ -146,35 +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)
+            AppSettings.objects.create(
+                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:
@@ -185,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
@@ -194,24 +223,18 @@ def restart(self, **kwargs):  # noqa
          Restart deployments with the kubectl rollout api
         """
         deployments = []
-        app_settings = self.appsettings_set.latest()
-        if 'type' in kwargs and kwargs['type'] in self.structure:
-            if self.structure[kwargs['type']] > 0:
-                if kwargs['type'] in app_settings.canaries:
-                    deployments.append(self._get_job_id(kwargs['type'], True))
-                deployments.append(self._get_job_id(kwargs['type'], False))
-        else:
-            for scale_type, count in self.structure.items():
-                if count > 0:
-                    if scale_type in app_settings.canaries:
-                        deployments.append(self._get_job_id(kwargs['type'], True))
-                    deployments.append(self._get_job_id(scale_type, False))
+        if self.structure[kwargs['type']] > 0:
+            deployments.append(self._get_deployment_name(kwargs['type']))
         try:
             tasks = [
-                functools.partial(
-                    self.scheduler().deployment.restart,
-                    self.id,
-                    deployment
+                (
+                    functools.partial(
+                        self.scheduler.deployment.restart,
+                        self.id,
+                        deployment
+                    ),
+                    lambda future: self.log(
+                        f'restart {kwargs['type']} callback: {future.result()}'),
                 ) for deployment in deployments
             ]
             apply_tasks(tasks)
@@ -221,151 +244,118 @@ def restart(self, **kwargs):  # noqa
 
     def scale(self, user, structure):
         err_msg = None
-        release = self.release_set.filter(failed=False).latest()
-        if (PROCFILE_TYPE_RUN in structure or release.build is None):
-            if PROCFILE_TYPE_RUN in structure:
+        release = Release.latest(self)
+        if (PTYPE_RUN in structure or release is None or release.build is None):
+            if PTYPE_RUN in structure:
                 err_msg = 'Cannot set scale for reserved types, procfile type is: run'
             else:
                 err_msg = 'No build associated with this release'
             self.log(err_msg, logging.WARNING)
             raise DryccException(err_msg)
         app_settings = self.appsettings_set.latest()
-        if release.canary:
-            self._scale(
-                user,
-                structure,
-                self.release_set.filter(failed=False, canary=False).latest(),
-                app_settings
-            )
         self._scale(user, structure, release, app_settings)
 
-    def pipeline(self, release, force_deploy=False, rollback_on_failure=True):
-        prefix = f"[pipeline] release v{release.version}"
+    def pipeline(self, release, ptypes, force_deploy=False):
+        prefix = f"[pipeline] release {release.version_name}"
         try:
-            self.log(f"{prefix} starts running...")
-            if release.build.dryccfile:
-                if 'run' in release.build.dryccfile:
-                    self.log(f"{prefix} starts running pipeline.run")
-                    job_name = self.run(
-                        self.owner, release.get_run_image(), command=release.get_run_command(),
-                        args=release.get_run_args(), timeout=settings.DRYCC_PILELINE_RUN_TIMEOUT,
-                        expires=settings.DRYCC_PILELINE_RUN_TIMEOUT)
-                    state, labels = 'initializing', {'job-name': job_name}
-                    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':
-                        raise DryccException(f'pipeline run state error: {state}')
-            self.log(f"{prefix} starts running pipeline.deploy")
-            self.deploy(release, None, force_deploy, rollback_on_failure)
-            release.state = "succeed"
+            if release is not None and release.build is not None:
+                if release.build.dryccfile:
+                    for run in release.get_runners(ptypes):
+                        self.log(f"{prefix} starts running pipeline.run {run['image']}")
+                        job_name = self.run(
+                            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(
+                                self.id, labels, settings.DRYCC_PILELINE_RUN_TIMEOUT)):
+                            self.log(f"{prefix} waiting for pipeline.run: {state} * {count}")
+                        if state != 'down':
+                            raise DryccException(f'pipeline run state error: {state}')
+                self.log(f"{prefix} starts running...")
+                rollback_on_failure = self.appsettings_set.latest().autorollback
+                if not rollback_on_failure:
+                    self.log(f"{prefix} deploy do not rollback on failure")
+                self.deploy(release, ptypes, force_deploy, rollback_on_failure)
+
+            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.state = "crashed"
-            release.failed = True
-            release.summary = "{} pipeline a release that failed".format(self.owner)
-            release.exception = "error: {}".format(str(e))
-            self.log(f"{prefix} pipeline runtime error: {release.exception}")
-        release.save()
+            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=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
         self.log(f"{prefix} run completed...")
 
-    def deploy(self, release, procfile_types=None, force_deploy=False, rollback_on_failure=True):
+    def deploy(self, release, ptypes=None, force_deploy=False, rollback_on_failure=True):
         """
         Deploy a new release to this application
 
         force_deploy can be used when a deployment is broken, such as for Rollback
         """
-        if release.build is None:
+        if release is None or release.build is None:
             raise DryccException('No build associated with this release')
-
         # use create to make sure minimum resources are created
         self.create()
         # Previous release
         prev_release = release.previous()
-
-        default_structure = self._default_structure(release)
-        if (self.structure != default_structure) or (
-            prev_release and prev_release.build and prev_release.build.type != release.build.type
-        ):
-            # structure {} or build type change, merge old structure if exists
-            for procfile_type, scale in default_structure.items():
-                default_structure[procfile_type] = self.structure.get(procfile_type, scale)
-            self.structure = default_structure
-            self.save()
+        self._merge_structure(release, prev_release)
         # deploy application to k8s. Also handles initial scaling
         app_settings = self.appsettings_set.latest()
         volumes = self.volume_set.all()
         deploys = {}
         for scale_type, replicas in self.structure.items():
-            if procfile_types is not None and scale_type not in procfile_types:
+            if ptypes is not None and scale_type not in ptypes:
                 continue
             scale_type_volumes = [_ for _ in volumes if scale_type in _.path.keys()]
-            if not release.canary or scale_type in app_settings.canaries:
-                deploys[scale_type] = self._gather_app_settings(
-                    release, app_settings, scale_type, replicas, volumes=scale_type_volumes)
+            deploys[scale_type] = self._gather_app_settings(
+                release, app_settings, scale_type, replicas, volumes=scale_type_volumes)
         self._deploy(
-            deploys, procfile_types, prev_release, release, force_deploy, rollback_on_failure)
+            deploys, ptypes, prev_release, release, force_deploy, rollback_on_failure)
         # cleanup old release objects from kubernetes
-        self.cleanup_old(procfile_types)
-        release.cleanup_old(procfile_types)
+        if app_settings.autodeploy:
+            self.clean(release=release)
+        release.clean(ptypes)
 
     def mount(self, user, volume, structure=None):
-        if self.release_set.filter(failed=False).latest().build is None:
+        release = Release.latest(self)
+        if release is None or release.build is None:
             raise DryccException('No build associated with this release')
-        release = self.release_set.filter(failed=False).latest()
         app_settings = self.appsettings_set.latest()
-        if release.canary:
-            self._mount(
-                user,
-                volume,
-                self.release_set.filter(failed=False, canary=False).latest(),
-                app_settings,
-                structure=structure,
-            )
-        self._mount(user, volume, release, app_settings, structure=structure)
-
-    def cleanup_old(self, procfile_types=None):
-        names, app_settings = [], self.appsettings_set.latest()
-        for scale_type in self.structure.keys():
-            if scale_type in app_settings.canaries:
-                names.append(self._get_job_id(scale_type, True))
-            names.append(self._get_job_id(scale_type, False))
+        self._mount(user, volume, app_settings, structure=structure)
+
+    def clean(self, release=None, ptypes=None):
+        release = release if release else self.release_set.latest()
+        # scale ptype down to 0, the next deploy will delete
+        pre_release = release.previous(None)
+        if pre_release is not None:
+            removed = {}
+            for ptype in pre_release.ptypes:
+                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._default_actor(), removed)
+        self._merge_structure(release, pre_release)
+        # clean k8s resources, ptype not in structure
         labels = {'heritage': 'drycc'}
-        if procfile_types:
-            labels["type__in"] = procfile_types
-        deployments = self.scheduler().deployments.get(self.id, labels=labels).json()["items"]
-        if deployments is not None:
-            for deployment in deployments:
-                name = deployment['metadata']['name']
-                if name not in names:
-                    self.scheduler().deployments.delete(self.id, name, True)
-        self.log(f"cleanup old kubernetes deployments for {self.id}")
-
-    @backoff.on_exception(backoff.expo, ServiceUnavailable, max_tries=3)
-    def logs(self, log_lines=str(settings.LOG_LINES)):
-        """Return aggregated log data for this application."""
-        url = "http://{}:{}/logs/{}?log_lines={}".format(
-            settings.LOGGER_HOST, settings.LOGGER_PORT, self.id, log_lines)
-        try:
-            r = requests.get(url)
-        # Handle HTTP request errors
-        except requests.exceptions.RequestException as e:
-            msg = "Error accessing drycc-logger using url '{}': {}".format(url, e)
-            logger.error(msg)
-            raise ServiceUnavailable(msg) from e
-
-        # Handle logs empty or not found
-        if r.status_code == 204 or r.status_code == 404:
-            logger.info("GET {} returned a {} status code".format(url, r.status_code))
-            raise NotFound('Could not locate logs')
-
-        # Handle unanticipated status codes
-        if r.status_code != 200:
-            logger.error("Error accessing drycc-logger: GET {} returned a {} status code"
-                         .format(url, r.status_code))
-            raise ServiceUnavailable('Error accessing drycc-logger')
-
-        # cast content to string since it comes as bytes via the requests object
-        return str(r.content.decode('utf-8'))
+        if ptypes:
+            labels["type__in"] = ptypes
+        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:
+                for resource in resources:
+                    name = resource['metadata']['name']
+                    ptype = resource['metadata'].get("labels", {}).get("type")
+                    if (ptype and ptype not in self.structure):
+                        api.delete(self.id, name, True)
+        self.log(f"cleanup old kubernetes resources for {self.id}")
 
     def run(self, user, image=None, command=None, args=None, volumes=None,
             timeout=3600, expires=3600, **kwargs):
@@ -373,71 +363,110 @@ def pod_name(size=5, chars=string.ascii_lowercase + string.digits):
             return ''.join(random.choice(chars) for _ in range(size))
 
         """Run a one-off command in an ephemeral app container."""
-        release = self.release_set.filter(failed=False).latest()
-        if release.build is None:
+        release = Release.latest(self)
+        if release is None or release.build is None:
             raise DryccException('No build associated with this release to run this command')
 
         app_settings = self.appsettings_set.latest()
         volume_list = []
         if volumes:
             for volume in Volume.objects.filter(app=self, name__in=volumes.keys()):
-                volume.path[PROCFILE_TYPE_RUN] = volumes.get(volume.name, None)
+                volume.path[PTYPE_RUN] = volumes.get(volume.name, None)
                 volume_list.append(volume)
         else:
             for volume in Volume.objects.filter(app=self):
-                if PROCFILE_TYPE_RUN in volume.path.keys():
+                if PTYPE_RUN in volume.path.keys():
                     volume_list.append(volume)
         data = self._gather_app_settings(
-            release, app_settings, procfile_type=PROCFILE_TYPE_RUN,
+            release, app_settings, ptype=PTYPE_RUN,
             replicas=1, volumes=volume_list)
         data['restart_policy'] = 'Never'
         data['active_deadline_seconds'] = timeout
         data['ttl_seconds_after_finished'] = expires
-        name = self._get_job_id(PROCFILE_TYPE_RUN, release.canary) + '-' + pod_name()
+        name = self._get_deployment_name(PTYPE_RUN) + '-' + pod_name()
         self.log("{} on {} runs '{}'".format(user.username, name, command))
         kwargs.update(data)
         try:
             # create application config and build the pod manifest
-            self.set_application_config(release, PROCFILE_TYPE_RUN)
-            self.scheduler().job.create(
-                self.id,
-                name,
-                image if image else release.get_run_image(),
-                command if command else release.get_run_command(),
-                args if args else release.get_run_args(),
-                **kwargs
-            )
+            self.set_application_config(release, PTYPE_RUN)
+            self.scheduler.job.create(self.id, name, image, command, args, **kwargs)
         except Exception as e:
-            err = '{} ({}): {}'.format(name, PROCFILE_TYPE_RUN, e)
+            err = '{} ({}): {}'.format(name, PTYPE_RUN, e)
             raise ServiceUnavailable(err) from e
         return name
 
+    def describe_pod(self, pod_name):
+        def get_command_and_args(pod, container_name):
+            command, args = [], []
+            for container in pod["spec"]["containers"]:
+                if container["name"] == container_name:
+                    args = container.get("args", [])
+                    command = container.get("command", [])
+                    break
+            return command, args
+        result = []
+        try:
+            pod = self.scheduler.pod.get(self.id, pod_name).json()
+            if pod["status"]['phase'] == 'Pending':
+                statuses = pod["spec"]["containers"]
+            else:
+                statuses = pod["status"]["containerStatuses"]
+            for status in statuses:
+                command, args = get_command_and_args(pod, status["name"])
+                result.append({
+                    "container": status["name"],
+                    "image": status["image"],
+                    "command": command,
+                    "args": args,
+                    "state": status.get("state", {}),
+                    "lastState": status.get("lastState", {}),
+                    "ready": status.get("ready", False),
+                    "restartCount": status.get("restartCount", 0),
+                    "status": pod["status"].get("phase", ""),
+                    "reason": pod["status"].get("reason", ""),
+                    "message": pod["status"].get("message", "")
+                })
+        except KubeHTTPException as e:
+            if e.response.status_code != 404:
+                raise e
+        return result
+
     def list_pods(self, *args, **kwargs):
         """Used to list basic information about pods running for a given application"""
         try:
             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 = []
             for p in pods:
-                item = {}
                 labels = p['metadata']['labels']
-                item['name'] = p['metadata']['name']
-                item['state'] = str(self.scheduler().pod.state(p))
-                item['release'] = labels['version']
-                item['type'] = labels['type']
-                # set start time
                 if 'startTime' in p['status']:
                     started = p['status']['startTime']
                 else:
                     started = str(
                         datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT))
-                item['started'] = started
+                state = str(self.scheduler.pod.state(p))
+                if p['status']['phase'] != 'Pending':
+                    ready = len([1 for s in p["status"]["containerStatuses"] if s['ready']])
+                    restarts = sum([s['restartCount'] for s in p["status"]["containerStatuses"]])
+                else:
+                    restarts = 0
+                    ready = 0
+                item = {
+                    'name': p['metadata']['name'],
+                    'state': state,
+                    'release': labels['version'], 'type': labels['type'], 'started': started,
+                    'ready': "%s/%s" % (
+                        ready,
+                        len(p["spec"]["containers"]),
+                    ),
+                    'restarts': restarts
+                }
                 data.append(item)
             # sorting so latest start date is first
             data.sort(key=lambda x: x['started'], reverse=True)
@@ -449,11 +478,115 @@ def list_pods(self, *args, **kwargs):
             self.log(err, logging.ERROR)
             raise ServiceUnavailable(err) from e
 
+    def delete_pod(self, **kwargs):
+        """Used to list basic information about pods running for a given application"""
+        pod_name = kwargs.get('pod_name')
+        try:
+            # make sure the pod is manageed by drycc
+            pod = self.scheduler.pod.get(self.id, pod_name).json()
+            if pod['metadata']['labels'].get("heritage") == "drycc":
+                self.scheduler.pod.delete(self.id, pod_name)
+        except KubeHTTPException as e:
+            # Sometimes k8s will manage to remove the pod from under us
+            if e.response.status_code != 404:
+                raise e
+
+    def describe_deployment(self, deployment_name):
+        result = []
+        try:
+            deployment = self.scheduler.deployment.get(self.id, deployment_name).json()
+            for container in deployment["spec"]["template"]['spec']["containers"]:
+                limits = container.get("resources", {}).get("limits", {})
+                result.append({
+                    "container": container["name"],
+                    "image": container["image"],
+                    "command": container.get("command", []),
+                    "args": container.get("args", []),
+                    "liveness_probe": container.get("livenessProbe", {}),
+                    "readiness_probe": container.get("readinessProbe", {}),
+                    "limits": limits,
+                    "volume_mounts": container.get("volumeMounts", []),
+                    "node_selector": deployment["spec"]["template"]['spec'].get("nodeSelector", {})  # noqa
+                })
+        except KubeHTTPException as e:
+            if e.response.status_code != 404:
+                raise e
+        return result
+
+    def list_deployments(self, *args, **kwargs):
+        """Used to list basic information about deployments running for a given application"""
+        ptypes = self.release_set.latest().ptypes
+        try:
+            labels = self._scheduler_filter(**kwargs)
+            # in case a singular deployment is requested
+            if 'name' in kwargs:
+                deployments = [self.scheduler.deployment.get(self.id, kwargs['name']).json()]
+            else:
+                deployments = self.scheduler.deployment.get(self.id, labels=labels).json()['items']  # noqa
+                if not deployments:
+                    deployments = []
+            data = []
+            for p in deployments:
+                labels = p['spec']['template']['metadata']['labels']
+                if p['metadata']['creationTimestamp']:
+                    started = p['metadata']['creationTimestamp']
+                else:
+                    started = str(
+                        datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT))
+                item = {
+                    'name': labels['type'],
+                    'release': labels['version'],
+                    'ready': "%s/%s" % (
+                        p["status"].get("readyReplicas", 0),
+                        p['spec'].get("replicas", 0),
+                    ),
+                    'garbage': False if labels['type'] in ptypes else True,
+                    'up_to_date': p["status"].get("updatedReplicas", 0),
+                    'available_replicas': p["status"].get("availableReplicas", 0),
+                    'started': started
+                }
+                data.append(item)
+            # sorting so latest start date is first
+            data.sort(key=lambda x: x['started'], reverse=True)
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = '(list deployments): {}'.format(e)
+            self.log(err, logging.ERROR)
+            raise ServiceUnavailable(err) from e
+
+    def list_events(self, ref_kind, ref_name, *args, **kwargs):
+        try:
+            fields = {
+                "regarding.kind": ref_kind,
+                "regarding.name": ref_name
+            }
+            kwargs["fields"] = fields
+            events = self.scheduler.events.get(self.id, **kwargs).json()['items']  # noqa
+            data = []
+            for e in events:
+                item = {
+                    'reason': e['reason'],
+                    'message': e['note'],
+                    'created': e['metadata']['creationTimestamp']
+                }
+                data.append(item)
+            # sorting so latest start date is first
+            data.sort(key=lambda x: x['created'], reverse=False)
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = '(list event): {}'.format(e)
+            self.log(err, logging.ERROR)
+            raise ServiceUnavailable(err) from e
+
     def autoscale(self, proc_type, autoscale):
         """
         Set autoscale rules for the application
         """
-        if proc_type == PROCFILE_TYPE_RUN:
+        if proc_type == PTYPE_RUN:
             raise DryccException('Cannot set autoscale for reserved types, procfile type is: run')
         name = '{}-{}'.format(self.id, proc_type)
         # basically fake out a Deployment object (only thing we use) to assign to the HPA
@@ -464,43 +597,43 @@ 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:
                 # let the user know about any other errors
                 raise ServiceUnavailable(str(e)) from e
 
-    def image_pull_secret(self, namespace, registry, image):
+    def image_pull_secret(self, namespace, ptype, registry, image):
         """
         Take registry information and set as an imagePullSecret for an RC / Deployment
         http://kubernetes.io/docs/user-guide/images/#specifying-imagepullsecrets-on-a-pod
         """
-        docker_config, name, create = self._get_private_registry_config(image, registry)
+        docker_config, name, create = self._get_private_registry_config(ptype, image, registry)
         if create is None:
             return
         elif create:
             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,
@@ -509,102 +642,114 @@ def image_pull_secret(self, namespace, registry, image):
 
         return name
 
-    def set_application_config(self, release, procfile_type):
+    def state_to_k8s(self):
+        release = Release.latest(self)
+        if release is None or release.build is None:
+            self.log('the last release does not have a build, skipping deployment...')
+            return
+        ptypes = set()
+        for ptype, scale in self.structure.items():
+            response = self.scheduler.deployment.get(
+                self.id, self._get_deployment_name(ptype),
+                ignore_exception=True)
+            if response.status_code == 404 and scale > 0:
+                ptypes.add(ptype)
+            elif response.status_code != 200:
+                data = response.json()
+                self.log('get deployment status_code {}, message: {}'.format(
+                    response.status_code, data.get("message", "")), logging.ERROR)
+        if len(ptypes) == 0:
+            self.log('the cluster status is the latest, skipping deployment...')
+            return
+        self.deploy(release, ptypes, False, False)
+
+    def set_application_config(self, release, ptype):
         """
         Creates the application config as a secret in Kubernetes and
         updates it if it already exists
         """
         # env vars are stored in secrets and mapped to env in k8s
-        version = 'v{}'.format(release.version)
         labels = {
-            'version': version,
-            'type': procfile_type,
+            'version': release.version_name,
+            'type': ptype,
             'class': 'env'
         }
 
         # secrets use dns labels for keys, map those properly here
         secrets_env = {}
-        for key, value in self._build_env_vars(release, procfile_type).items():
+        for key, value in self._build_env_vars(release, ptype).items():
             secrets_env[key.lower().replace('_', '-')] = str(value)
 
         # dictionary sorted by key
         secrets_env = OrderedDict(sorted(secrets_env.items(), key=lambda t: t[0]))
 
-        secret_name = "{}-{}-{}-env".format(self.id, procfile_type, version)
+        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 container_type, scale in self.structure.items():
-            plan = config.limits.get(container_type)
-            measurements.append({
+        for ptype, scale in self.structure.items():
+            plan = config.limits.get(ptype)
+            usage.append({
                 "app_id": str(self.uuid),
-                "owner": self.owner_id,
+                "workspace": self.workspace_id,
                 "name": plan,
                 "type": "limits",
                 "unit": "number",
                 "usage": scale,
                 "kwargs": {
-                    "procfile_type": container_type,
+                    "ptype": ptype,
                 },
                 "timestamp": int(timestamp),
+                "identifier": self.uuid.hex,
             })
-        return measurements
+        return usage
 
     def __str__(self):
         return self.id
 
-    def _get_job_id(self, container_type, canary):
-        job_id = f"{self.id}-{container_type}"
-        if canary:
-            job_id = f"{job_id}-canary"
-        return job_id
-
-    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 _get_deployment_name(self, ptype):
+        return f"{self.id}-{ptype}"
 
-    def _mount(self, user, volume, release, app_settings, structure=None):
+    def _mount(self, user, volume, app_settings, structure=None):
         volumes = Volume.objects.filter(app=self)
         tasks = []
         for scale_type, replicas in structure.items() if structure else self.structure.items():
-            if scale_type != PROCFILE_TYPE_RUN and (
-                    not release.canary or scale_type in app_settings.canaries):
+            if scale_type != PTYPE_RUN:
+                release = self.release_set.filter(
+                    deployed_ptypes__contains=scale_type,
+                    failed=False).latest()
                 replicas = self.structure.get(scale_type, 0)
                 scale_type_volumes = [
                     volume for volume in volumes if scale_type in volume.path.keys()]
                 data = self._gather_app_settings(
                     release, app_settings, scale_type, replicas, volumes=scale_type_volumes)
-                deployment = self.scheduler().deployment.get(
-                    self.id, self._get_job_id(scale_type, release.canary)).json()
+                deployment = self.scheduler.deployment.get(
+                    self.id, self._get_deployment_name(scale_type)).json()
                 spec_annotations = deployment['spec']['template']['metadata'].get(
                     'annotations', {})
                 self.set_application_config(release, scale_type)
                 # gather volume proc types to be deployed
-                tasks.append(functools.partial(
-                    self.scheduler().deployment.patch,
-                    namespace=self.id,
-                    name=self._get_job_id(scale_type, release.canary),
-                    image=release.get_deploy_image(scale_type),
-                    command=release.get_deploy_command(scale_type),
-                    args=release.get_deploy_args(scale_type),
-                    spec_annotations=spec_annotations,
-                    resource_version=deployment["metadata"]["resourceVersion"],
-                    **data
+                tasks.append((
+                    functools.partial(
+                        self.scheduler.deployment.patch,
+                        namespace=self.id,
+                        name=self._get_deployment_name(scale_type),
+                        image=release.get_deploy_image(scale_type),
+                        command=release.get_deploy_command(scale_type),
+                        args=release.get_deploy_args(scale_type),
+                        spec_annotations=spec_annotations,
+                        resource_version=deployment["metadata"]["resourceVersion"],
+                        **data
+                    ),
+                    lambda future: self.log(
+                        f'mount {volume} for {scale_type} callback: {future.result()}'),
                 ))
         try:
             apply_tasks(tasks)
@@ -614,43 +759,45 @@ def _mount(self, user, volume, release, app_settings, structure=None):
             raise ServiceUnavailable(err) from e
         self.log(f'{user.username} changed volume mount for {volume}')
 
-    def _deploy(self, deploys, procfile_types, prev_release,
+    def _deploy(self, deploys, ptypes, prev_release,
                 release, force_deploy, rollback_on_failure):
         # Sort deploys so routable comes first
         deploys = OrderedDict(sorted(deploys.items(), key=lambda d: d[1].get('routable')))
         # Check if any proc type has a Deployment in progress
-        self._check_deployment_in_progress(deploys, release, force_deploy)
-
+        self._check_deployment_in_progress(deploys, force_deploy)
         try:
             tasks = []
+            lock = DeployLock(self.pk)
             for scale_type, kwargs in deploys.items():
                 self.set_application_config(release, scale_type)
-                tasks.append(functools.partial(
-                    self.scheduler().deploy,
-                    namespace=self.id,
-                    name=self._get_job_id(scale_type, release.canary),
-                    image=release.get_deploy_image(scale_type),
-                    command=release.get_deploy_command(scale_type),
-                    args=release.get_deploy_args(scale_type),
-                    **kwargs
+                tasks.append((
+                    functools.partial(
+                        self.scheduler.deploy,
+                        namespace=self.id,
+                        name=self._get_deployment_name(scale_type),
+                        image=release.get_deploy_image(scale_type),
+                        command=release.get_deploy_command(scale_type),
+                        args=release.get_deploy_args(scale_type),
+                        **kwargs
+                    ),
+                    lambda future: self.log(
+                        f'deploy and unlock callback: {[
+                            future.result(), lock.release([scale_type])]}'),
                 ))
             try:
                 apply_tasks(tasks)
             except KubeException as e:
                 # Don't rollback if the previous release doesn't have a build which means
                 # this is the first build and all the previous releases are just config changes.
-                if (prev_release.canary == release.canary and
-                        rollback_on_failure and prev_release.build is not None):
+                if rollback_on_failure and prev_release is not None and prev_release.build is not None:  # noqa
                     err = 'There was a problem deploying {}. Rolling back to release {}.'.format(
-                        'v{}'.format(release.version), "v{}".format(prev_release.version))
+                        release.version_name, prev_release.version_name)
                     # This goes in the log before the rollback starts
                     self.log(err, logging.ERROR)
                     # revert all process types to old release
-                    self.deploy(prev_release, procfile_types,
-                                force_deploy=True, rollback_on_failure=False)
+                    self.deploy(prev_release, ptypes, True, False)
                     # let it bubble up
                     raise DryccException('{}\n{}'.format(err, str(e))) from e
-
                 # otherwise just re-raise
                 raise
         except Exception as e:
@@ -658,54 +805,52 @@ def _deploy(self, deploys, procfile_types, prev_release,
             err = '(app::deploy): {}'.format(e)
             self.log(err, logging.ERROR)
             raise ServiceUnavailable(err) from e
-        for procfile_type, value in deploys.items():
-            if procfile_type == PROCFILE_TYPE_WEB:  # http
-                target_port = int(value.get('envs', {}).get('PORT', DEFAULT_CONTAINER_PORT))
+        for ptype in deploys.keys():
+            if ptype == PTYPE_WEB:  # http
+                target_port = release.get_port(ptype)
                 self._create_default_ingress(target_port)
-            service = self.service_set.filter(procfile_type=procfile_type).first()
+            service = self.service_set.filter(ptype=ptype).first()
             if not service:
                 continue
             if prev_release and prev_release.build:
                 continue
-            if procfile_type == PROCFILE_TYPE_WEB:
-                self._verify_http_health(service, **deploys[procfile_type])
+            if ptype == PTYPE_WEB:
+                self._verify_http_health(service, **deploys[ptype])
             else:
-                self._verify_tcp_health(service, **deploys[procfile_type])
+                self._verify_tcp_health(service, **deploys[ptype])
 
     def _scale(self, user, structure, release, app_settings):  # noqa
         """Scale containers up or down to match requested structure."""
         # use create to make sure minimum resources are created
         self.create()
-
         # Validate structure
         try:
             for target, count in structure.copy().items():
                 structure[target] = int(count)
             validate_app_structure(structure)
         except (TypeError, ValueError, ValidationError) as e:
-            raise DryccException('Invalid scaling format: {}'.format(e))
-
-        # test for available process types
-        for container_type in structure:
-            if container_type not in (PROCFILE_TYPE_WEB, PROCFILE_TYPE_RUN) and \
-                    container_type not in release.procfile_types:
-                raise NotFound(
-                    'Container type {} does not exist in application'.format(container_type))
-
-        # merge current structure and the new items together
-        old_structure = self.structure
-        new_structure = old_structure.copy()
-        new_structure.update(structure)
-        if new_structure != self.structure:
+            err_msg = 'Invalid scaling format: {}'.format(e)
+            self.log(err_msg)
+            raise DryccException(err_msg)
+
+        new_scale = dict_diff(structure, self.structure).get("changed", {})
+        old_scale = dict_diff(self.structure, structure).get("changed", {})
+
+        if new_scale:
             try:
-                self._scale_pods(structure, release, app_settings)
+                self._scale_pods(new_scale, release, app_settings)
             except ServiceUnavailable:
                 # scaling failed, go back to old scaling numbers
-                self._scale_pods(old_structure, release, app_settings)
+                self._scale_pods(old_scale, release, app_settings)
                 raise
             # save new structure to the database
-            self.structure = new_structure
-            self.save()
+            App.objects.filter(id=self.id).update(
+                structure=Func(
+                    F("structure"),
+                    Value(new_scale, JSONField()),
+                    function="jsonb_concat",
+                )
+            )
             msg = '{} scaled pods '.format(user.username) + ' '.join(
                 "{}={}".format(k, v) for k, v in list(structure.items()))
             self.log(msg)
@@ -723,17 +868,18 @@ def _scale_pods(self, scale_types, release, app_settings):
             # create the application config in k8s (secret in this case) for all deploy objects
             self.set_application_config(release, scale_type)
             # gather all proc types to be deployed
-            tasks.append(
+            tasks.append((
                 functools.partial(
-                    self.scheduler().scale,
+                    self.scheduler.scale,
                     namespace=self.id,
-                    name=self._get_job_id(scale_type, release.canary),
+                    name=self._get_deployment_name(scale_type),
                     image=release.get_deploy_image(scale_type),
                     command=release.get_deploy_command(scale_type),
                     args=release.get_deploy_args(scale_type),
                     **data
-                )
-            )
+                ),
+                lambda future: self.log(f'scale {scale_type} callback: {future.result()}'),
+            ))
         try:
             apply_tasks(tasks)
         except Exception as e:
@@ -741,24 +887,24 @@ def _scale_pods(self, scale_types, release, app_settings):
             self.log(err, logging.ERROR)
             raise ServiceUnavailable(err) from e
 
-    def _set_default_limit(self, config, procfile_type):
-        if procfile_type not in config.limits:
+    def _set_default_limit(self, config, ptype):
+        if ptype not in config.limits:
             plan = LimitPlan.get_default()
-            config.limits[procfile_type] = plan.id
+            config.limits[ptype] = plan.id
             config.save(update_fields=['limits'])
         return config
 
     def _set_default_config(self):
         plan = LimitPlan.get_default()
-        limits = {PROCFILE_TYPE_WEB: plan.id, PROCFILE_TYPE_RUN: plan.id}
+        limits = {PTYPE_WEB: plan.id, PTYPE_RUN: plan.id}
         try:
             config = self.config_set.latest()
-            limits[PROCFILE_TYPE_WEB] = config.limits.get(PROCFILE_TYPE_WEB, plan.id)
-            limits[PROCFILE_TYPE_RUN] = config.limits.get(PROCFILE_TYPE_RUN, plan.id)
+            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)
-        for procfile_type in self.procfile_types:
-            limits[procfile_type] = config.limits.get(procfile_type, plan.id)
+            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:
             config.limits = limits
             config.save(update_fields=['limits'])
@@ -767,9 +913,9 @@ def _set_default_config(self):
     def _create_default_ingress(self, target_port):
         # create default service
         try:
-            service = self.service_set.filter(procfile_type=PROCFILE_TYPE_WEB).latest()
+            service = self.service_set.filter(ptype=PTYPE_WEB).latest()
         except Service.DoesNotExist:
-            service = Service(owner=self.owner, app=self, procfile_type=PROCFILE_TYPE_WEB)
+            service = Service(app=self, ptype=PTYPE_WEB)
             service.add_port(DEFAULT_HTTP_PORT, "TCP", target_port)
             service.save()
         else:
@@ -781,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)
@@ -792,9 +938,9 @@ 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,
-                          port=DEFAULT_HTTP_PORT, procfile_type=service.procfile_type)
-            route.rules = route.default_rules
+            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)
             if not attached:
                 raise DryccException(msg)
@@ -831,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
@@ -876,13 +1023,13 @@ def _verify_tcp_health(self, service, **kwargs):
                 else:
                     time.sleep(3)
 
-    def _check_deployment_in_progress(self, deploys, release, force_deploy=False):
+    def _check_deployment_in_progress(self, deploys, force_deploy=False):
         if force_deploy:
             return
         for scale_type, kwargs in deploys.items():
-            name = self._get_job_id(scale_type, release.canary)
+            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")
             )
@@ -890,14 +1037,29 @@ def _check_deployment_in_progress(self, deploys, release, force_deploy=False):
             if in_progress and not deploy_okay:
                 raise AlreadyExists('Deployment for {} is already in progress'.format(name))
 
-    @staticmethod
-    def _default_structure(release):
+    def _merge_structure(self, release, prev_release):
         """Scale to default structure based on release type"""
-        structure = {PROCFILE_TYPE_WEB: 1}
-        for procfile_type in release.procfile_types:
-            if procfile_type != PROCFILE_TYPE_WEB:
-                structure[procfile_type] = 0
-        return structure
+        lock = self.lock()
+        try:
+            lock.acquire()
+            self.refresh_from_db()
+            default_structure = {}
+            for ptype in release.ptypes:
+                default_structure[ptype] = 1 if ptype == PTYPE_WEB else 0
+            if (self.structure != default_structure) or (
+                prev_release and prev_release.build and
+                prev_release.build.type != release.build.type
+            ):
+                for ptype, scale in self.structure.items():
+                    # clean old ptype
+                    if ptype not in release.ptypes and scale == 0:
+                        continue
+                    default_structure[ptype] = scale
+                self.structure = default_structure
+                self.save()
+        finally:
+            lock.release()
+        return self.structure
 
     def _scheduler_filter(self, **kwargs):
         labels = {'app': self.id, 'heritage': 'drycc'}
@@ -911,36 +1073,34 @@ def _scheduler_filter(self, **kwargs):
             labels.update({'version': version})
         return labels
 
-    def _build_env_vars(self, release, procfile_type):
+    def _build_env_vars(self, release, ptype):
         """
         Build a dict of env vars, setting default vars based on app type
         and then combining with the user set ones
         """
-        if release.build is None:
+        if release is None or release.build is None:
             raise DryccException('No build associated with this release to run this command')
 
         # mix in default environment information drycc may require
         default_env = {
             'DRYCC_APP': self.id,
-            'WORKFLOW_RELEASE': 'v{}'.format(release.version),
+            'WORKFLOW_RELEASE': release.version_name,
             'WORKFLOW_RELEASE_SUMMARY': release.summary,
             'WORKFLOW_RELEASE_CREATED_AT': str(release.created.strftime(
                 settings.DRYCC_DATETIME_FORMAT))
         }
 
         default_env['SOURCE_VERSION'] = release.build.sha
-
+        # merge envs on top of default to make envs win
+        default_env.update(release.config.envs(ptype))
         # fetch application port and inject into ENV vars as needed
-        port = release.get_port()
+        port = release.get_port(ptype)
         if port:
             default_env['PORT'] = port
-        # merge envs on top of default to make envs win
-        default_env.update(release.config.values)
-        default_env.update(release.config.typed_values.get(procfile_type, {}))
         return default_env
 
-    def _get_private_registry_config(self, image, registry=None):
-        name = settings.REGISTRY_SECRET_PREFIX
+    def _get_private_registry_config(self, ptype, image, registry=None):
+        name = settings.REGISTRY_SECRET_PREFIX + '-' + ptype
         if registry:
             # try to get the hostname information
             hostname = registry.get('hostname', None)
@@ -953,13 +1113,13 @@ def _get_private_registry_config(self, 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
@@ -976,21 +1136,17 @@ def _get_private_registry_config(self, image, registry=None):
         })
         return docker_config, name, True
 
-    def _get_volumes_and_mounts(self, procfile_type, volumes):
+    def _get_volumes_and_mounts(self, ptype, volumes):
         k8s_volumes, k8s_volume_mounts = [], []
         if volumes:
             for volume in volumes:
-                k8s_volume = {"name": volume.name}
-                if volume.type == "csi":
-                    k8s_volume.update({"persistentVolumeClaim": {"claimName": volume.name}})
-                else:
-                    k8s_volume.update(volume.parameters)
-                k8s_volumes.append(k8s_volume)
+                k8s_volumes.append(
+                    {"name": volume.name, "persistentVolumeClaim": {"claimName": volume.name}})
                 k8s_volume_mounts.append(
-                    {"name": volume.name, "mountPath": volume.path.get(procfile_type)})
+                    {"name": volume.name, "mountPath": volume.path.get(ptype)})
         return k8s_volumes, k8s_volume_mounts
 
-    def _gather_app_settings(self, release, app_settings, procfile_type, replicas, volumes=None):
+    def _gather_app_settings(self, release, app_settings, ptype, replicas, volumes=None):
         """
         Gathers all required information needed in one easy place for passing into
         the Kubernetes client to deploy an application
@@ -998,65 +1154,69 @@ def _gather_app_settings(self, release, app_settings, procfile_type, replicas, v
         Any global setting that can also be set per app goes here
         """
 
-        envs = self._build_env_vars(release, procfile_type)
+        envs = self._build_env_vars(release, ptype)
         # Obtain a limit plan that must exist, if raise error here, it must be a bug
-        config = self._set_default_limit(release.config, procfile_type)
-        limit_plan = LimitPlan.objects.get(id=config.limits.get(procfile_type))
+        config = self._set_default_limit(release.config, ptype)
+        limit_plan = LimitPlan.objects.get(id=config.limits.get(ptype))
 
         # see if the app config has deploy batch preference, otherwise use global
-        batches = int(config.values.get('DRYCC_DEPLOY_BATCHES', settings.DRYCC_DEPLOY_BATCHES))
+        batches = int(envs.get('DRYCC_DEPLOY_BATCHES', settings.DRYCC_DEPLOY_BATCHES))
 
         # see if the app config has deploy timeout preference, otherwise use global
-        deploy_timeout = int(
-            config.values.get('DRYCC_DEPLOY_TIMEOUT', settings.DRYCC_DEPLOY_TIMEOUT))
+        deploy_timeout = int(envs.get('DRYCC_DEPLOY_TIMEOUT', settings.DRYCC_DEPLOY_TIMEOUT))
 
         # configures how many ReplicaSets to keep beside the latest version
-        deployment_history = config.values.get(
+        deployment_history = envs.get(
             'KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT',
             settings.KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT)
 
         # get application level pod termination grace period
-        pod_termination_grace_period_seconds = int(config.values.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 = config.values.get('IMAGE_PULL_POLICY', settings.IMAGE_PULL_POLICY)
+        image_pull_policy = envs.get('IMAGE_PULL_POLICY', settings.IMAGE_PULL_POLICY)
 
+        # set registry
+        registry = config.registry.get(ptype, {})
         # create image pull secret if needed
         image_pull_secret_name = self.image_pull_secret(
-            self.id, config.registry, release.get_deploy_image(procfile_type))
+            self.id, ptype, registry, release.get_deploy_image(ptype))
 
         # only web is routable
         # https://www.drycc.cc/applications/managing-app-processes/#default-process-types
         routable = True if (
-            procfile_type == PROCFILE_TYPE_WEB and app_settings.routable) else False
+            ptype == PTYPE_WEB and app_settings.routable) else False
 
-        healthcheck = config.healthcheck.get(procfile_type, {})
-        volumes, volume_mounts = self._get_volumes_and_mounts(procfile_type, volumes)
+        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,
+            'tags': config.tags.get(ptype, {}),
             'envs': envs,
-            'registry': config.registry,
+            'registry': registry,
             'replicas': replicas,
-            'version': 'v{}'.format(release.version),
-            'app_type': procfile_type,
+            'version': release.version_name,
+            'app_type': ptype,
             'resources': {"limits": limit_plan.limits, "requests": limit_plan.requests},
             'build_type': release.build.type,
             'annotations': limit_plan.annotations,
-            'healthcheck': healthcheck,
-            'runtime_class_name': settings.DRYCC_APP_RUNTIME_CLASS,
+            '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 1021e4ae6..32c1da8f7 100644
--- a/rootfs/api/models/appsettings.py
+++ b/rootfs/api/models/appsettings.py
@@ -1,9 +1,12 @@
 import logging
+
+from functools import partial
 from django.db import models
 from django.db import transaction
 
 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
@@ -17,10 +20,10 @@ 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)
-    canaries = models.JSONField(default=list)
-    routable = models.BooleanField(default=True)
+    routable = models.BooleanField(default=None)
+    autodeploy = models.BooleanField(default=None)
+    autorollback = models.BooleanField(default=None)
     autoscale = models.JSONField(default=dict, blank=True)
     label = models.JSONField(default=dict, blank=True)
 
@@ -45,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):
@@ -63,28 +67,18 @@ def previous(self):
             prev_app_settings = None
         return prev_app_settings
 
-    def _update_canaries(self, previous_settings):
-        old = getattr(previous_settings, 'canaries', [])
-        new = getattr(self, 'canaries', [])
-        data = old.copy()
-        if data and not new:
-            setattr(self, 'canaries', data)
-        elif data != new:
-            for procfile_type in new:
-                if procfile_type not in data:
-                    data.append(procfile_type)
-            setattr(self, 'canaries', data)
-            self.summary += [
-                "{} add canaries for process types {}".format(self.owner, ','.join(new))]
-
-    def _update_routable(self, previous_settings):
-        old = getattr(previous_settings, 'routable', None)
-        new = getattr(self, 'routable', None)
+    def _update_field(self, field, previous_settings):
+        old = getattr(previous_settings, field, None)
+        new = getattr(self, field, None)
         # if nothing changed copy the settings from previous
         if new is None and old is not None:
-            setattr(self, 'routable', old)
+            setattr(self, field, old)
+        elif new is None and isinstance(self._meta.get_field(field), models.BooleanField):
+            setattr(self, field, True)
         elif old != new:
-            self.summary += ["{} changed routablity from {} to {}".format(self.owner, 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()
@@ -124,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()
@@ -156,19 +150,24 @@ 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)]
 
-    def _update_fields(self, ignore_update_fields=None):
+    @transaction.atomic
+    def save(self, ignore_update_fields=None, *args, **kwargs):
         previous_settings = None
         try:
             previous_settings = self.app.appsettings_set.latest()
         except AppSettings.DoesNotExist:
             pass
-        update_fields = ["canaries", "routable", "autoscale", "label"]
+        update_fields = ["routable", "autodeploy", "autorollback", "autoscale", "label"]
         try:
-            for update_field in update_fields:
-                if ignore_update_fields is None or update_field not in ignore_update_fields:
-                    getattr(self, "_update_%s" % update_field)(previous_settings)
+            for field in update_fields:
+                if ignore_update_fields is None or field not in ignore_update_fields:
+                    getattr(
+                        self,
+                        "_update_%s" % field,
+                        partial(self._update_field, field)
+                    )(previous_settings)
         except (UnprocessableEntity, NotFound):
             raise
         except Exception as e:
@@ -177,28 +176,7 @@ def _update_fields(self, ignore_update_fields=None):
 
         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)
-
-    def diff_canaries(self):
-        prev_app_settings = self.previous()
-        action, canaries = None, []
-        if prev_app_settings is not None:
-            if prev_app_settings.canaries != self.canaries:
-                for procfile_type in self.canaries:  # add canary
-                    if procfile_type not in prev_app_settings.canaries:
-                        if action is None:
-                            action = "append"
-                        canaries.append(procfile_type)
-                for procfile_type in prev_app_settings.canaries:  # delete canary
-                    if procfile_type not in self.canaries:
-                        if action is None:
-                            action = "remove"
-                        canaries.append(procfile_type)
-        return prev_app_settings, action, canaries
-
-    @transaction.atomic
-    def save(self, ignore_update_field=None, *args, **kwargs):
-        self._update_fields(ignore_update_field)
-        super(AppSettings, self).save(**kwargs)
diff --git a/rootfs/api/models/base.py b/rootfs/api/models/base.py
index b59563906..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#",
@@ -27,9 +26,12 @@
         "access_token", "expires_in", "token_type", "scope", "refresh_token"
     ],
 }
-
-
-def get_anonymous_user_instance(user): return user(id=-1, username=settings.ANONYMOUS_USER_NAME)
+PTYPE_WEB = "web"
+PTYPE_RUN = "run"
+DEFAULT_HTTP_PORT = 80
+DEFAULT_HTTPS_PORT = 443
+PTYPE_MIN_LENGTH = 3
+PTYPE_MAX_LENGTH = 63
 
 
 class AuditedModel(models.Model):
@@ -42,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):
@@ -96,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 0af54e2dc..96bee571b 100644
--- a/rootfs/api/models/build.py
+++ b/rootfs/api/models/build.py
@@ -1,10 +1,12 @@
 import logging
-from django.conf import settings
+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, Conflict
-from api.tasks import run_pipeline
-from .base import UuidAuditedModel
+from api.exceptions import DryccException
+from .base import UuidAuditedModel, PTYPE_WEB
+from .config import Config
+from .release import Release
 
 User = get_user_model()
 logger = logging.getLogger(__name__)
@@ -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)
@@ -43,10 +44,12 @@ def type(self):
             return 'image'
 
     @property
-    def procfile_types(self):
+    def ptypes(self):
         if self.dryccfile:
-            return list(self.dryccfile['deploy'].keys())
-        return list(self.procfile.keys())
+            return list([pipeline['ptype'] for pipeline in self.dryccfile['pipeline'].values()])
+        if self.procfile:
+            return list(self.procfile.keys())
+        return [PTYPE_WEB]
 
     @property
     def source_based(self):
@@ -61,95 +64,98 @@ def source_based(self):
     def version(self):
         return 'git-{}'.format(self.sha) if self.source_based else 'latest'
 
-    def get_image(self, procfile_type, default_image=None):
-        docker = self.dryccfile.get('build', {}).get('docker', {})
-        if procfile_type in docker:
-            if procfile_type == 'web':
-                return self.image
-            else:
-                return f'{self.image}-{procfile_type}'
+    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:
+            return self.image if ptype == 'web' else f'{self.image}-{ptype}'
         return default_image if default_image else self.image
 
+    def get_pipeline(self, ptype):
+        pipelines = self.get_pipelines([ptype])
+        if len(pipelines) > 0:
+            return pipelines[0]
+        return {}
+
+    def get_pipelines(self, ptypes):
+        results = []
+        for pipeline in self.dryccfile.get('pipeline', {}).values():
+            if pipeline['ptype'] in ptypes:
+                results.append(pipeline)
+        return results
+
     def create_release(self, user, *args, **kwargs):
-        app_settings = self.app.appsettings_set.latest()
-        latest_release = self.app.release_set.filter(failed=False).latest()
-        latest_version = self.app.release_set.latest().version
+        latest_release = Release.latest(self.app)
         try:
             new_release = latest_release.new(
                 user,
                 build=self,
-                config=latest_release.config,
-                canary=len(app_settings.canaries) > 0,
+                config=self._get_or_create_config(),
             )
-            run_pipeline.delay(new_release)
+            if self.app.appsettings_set.latest().autodeploy:
+                new_release.deploy(force_deploy=False)
             return new_release
         except Exception as e:
             # check if the exception is during create or publish
+            latest_version = self.app.release_set.latest().version
             if ('new_release' not in locals() and
                     self.app.release_set.latest().version == latest_version+1):
                 new_release = self.app.release_set.latest()
                 new_release.state = "crashed"
                 new_release.failed = True
-                new_release.summary = "{} deployed {} which failed".format(self.owner, str(self.uuid)[:7])  # noqa
+                if new_release.summary:
+                    new_release.summary += " "
+                new_release.summary += "{} deployed {} which failed".format(
+                    user, str(self.uuid)[:7])
                 # Get the exception that has occured
                 new_release.exception = "error: {}".format(str(e))
-                new_release.save()
+                # avoid overwriting other fields
+                new_release.save(update_fields=["state", "failed", "summary", "exception"])
             if 'new_release' not in locals():
                 self.delete()
             raise DryccException(str(e)) from e
 
-    def save(self, **kwargs):
-        previous_release = self.app.release_set.filter(failed=False).latest()
-
-        if (
-            settings.DRYCC_DEPLOY_REJECT_IF_PROCFILE_MISSING is True and
-            # previous release had a Procfile and the current one does not
-            (
-                previous_release.build is not None and
-                len(previous_release.procfile_types) > 0 and
-                len(self.procfile_types) == 0
-            )
-        ):
-            # Reject deployment
-            raise Conflict(
-                'Last deployment had process types but is missing in this deploy. '
-                'For a successful deployment provide process types.'
-            )
-
-        # See if processes are permitted to be removed
-        remove_procs = (
-            # If set to True then contents of Procfile does not affect the outcome
-            settings.DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE is True or
-            # previous release had a Procfile and the current one does as well
-            (
-                previous_release.build is not None and
-                len(previous_release.procfile_types) > 0 and
-                len(self.procfile_types) > 0
-            )
-        )
-
-        # spin down any proc type removed between the last procfile and the newest one
-        if remove_procs and previous_release.build is not None:
-            removed = {}
-            for proc in previous_release.procfile_types:
-                if proc not in self.procfile_types and self.app.structure.get(proc, 0) > 0:
-                    # Scale proc type down to 0
-                    removed[proc] = 0
-
-            self.app.scale(self.owner, removed)
-
-        # make sure the latest build has procfile if the intent is to
-        # allow empty Procfile without removals
-        if (
-            settings.DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE is False and
-            previous_release.build is not None and
-            len(previous_release.procfile_types) > 0 and
-            len(self.procfile_types) == 0
-        ):
-            self.procfile = previous_release.build.procfile
-            self.dryccfile = previous_release.build.dryccfile
-
-        return super(Build, self).save(**kwargs)
-
     def __str__(self):
         return "{0}-{1}".format(self.app.id, str(self.uuid)[:7])
+
+    def _get_or_create_config(self):
+        """
+        dryccfile to config
+        """
+        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():
+                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():
+                    values.append({"name": key, "ptype": pipeline['ptype'], "value": value})
+                for config_ref in pipeline.get('config', []):
+                    if pipeline['ptype'] not in values_ref:
+                        values_ref[pipeline['ptype']] = [config_ref]
+                    else:
+                        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 1e1d862a3..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
@@ -13,7 +11,7 @@
 from django.contrib.auth import get_user_model
 from rest_framework.exceptions import ValidationError
 from api.utils import validate_label
-from api.exceptions import AlreadyExists, ServiceUnavailable
+from api.exceptions import ServiceUnavailable
 from scheduler import KubeException
 from .base import AuditedModel
 from .domain import Domain
@@ -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 []
 
-    # 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)
+    return san_extension.value.get_values_for_type(x509.DNSName)
 
 
-# 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 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()
+    )
+
+
+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,8 +94,8 @@ class Certificate(AuditedModel):
     """
     Public and private key pair used to secure application traffic at the router.
     """
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
-    name = models.CharField(max_length=253, unique=True, validators=[validate_label])
+    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
     certificate = models.TextField(validators=[validate_certificate])
     key = models.TextField(validators=[validate_private_key])
@@ -121,6 +113,7 @@ class Certificate(AuditedModel):
 
     class Meta:
         ordering = ['name', 'common_name', 'expires']
+        unique_together = ('app', 'name')
 
     @property
     def domains(self):
@@ -130,6 +123,10 @@ def domains(self):
 
         return domains
 
+    @property
+    def certname(self):
+        return '%s-certificate' % self.name
+
     def __str__(self):
         return self.name
 
@@ -138,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)
@@ -186,8 +175,6 @@ def delete(self, *args, **kwargs):
     def attach(self, *args, **kwargs):
         # add the certificate to the domain
         domain = get_object_or_404(Domain, domain=kwargs['domain'])
-        if domain.certificate is not None:
-            raise AlreadyExists("Domain already has a certificate attached to it")
         # create in kubernetes
         self.attach_in_kubernetes(domain)
         domain.certificate = self
@@ -197,24 +184,23 @@ def attach_in_kubernetes(self, domain):
         """Creates the certificate as a kubernetes secret"""
         # only create if it exists - We raise an exception when a secret doesn't exist
         try:
-            name = '%s-certificate' % self.name
             namespace = domain.app.id
             data = {
                 'tls.crt': self.certificate,
                 'tls.key': self.key
             }
 
-            secret = self.scheduler().secret.get(namespace, name).json()['data']
+            secret = self.scheduler.secret.get(namespace, self.certname).json()['data']
         except KubeException:
-            self.scheduler().secret.create(namespace, name, 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, name, data)
+                    self.scheduler.secret.update(namespace, self.certname, data)
                 except KubeException as e:
                     msg = 'There was a problem updating the certificate secret ' \
-                          '{} for {}'.format(name, namespace)
+                          '{} for {}'.format(self.certname, namespace)
                     raise ServiceUnavailable(msg) from e
 
     def detach(self, *args, **kwargs):
@@ -223,14 +209,15 @@ def detach(self, *args, **kwargs):
         domain.certificate = None
         domain.save()
 
-        name = '%s-certificate' % self.name
         namespace = domain.app.id
 
         # only delete if it exists and if no other domains depend on secret
         if len(self.domains) == 0:
             try:
                 # We raise an exception when a secret doesn't exist
-                self.scheduler().secret.get(namespace, name)
-                self.scheduler().secret.delete(namespace, name)
+                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(name, namespace)) from e  # noqa
+                raise ServiceUnavailable(
+                    "Could not delete certificate secret {} for application {}".format(
+                        self.certname, namespace)) from e
diff --git a/rootfs/api/models/config.py b/rootfs/api/models/config.py
index 8eba125cc..51bc0cf9f 100644
--- a/rootfs/api/models/config.py
+++ b/rootfs/api/models/config.py
@@ -1,6 +1,5 @@
-import json
 import logging
-from django.conf import settings
+from functools import partial
 from django.db import models
 from django.contrib.auth import get_user_model
 from api.utils import dict_diff
@@ -17,19 +16,17 @@ class Config(UuidAuditedModel):
     Set of configuration values applied as environment variables
     during runtime execution of the Application.
     """
-    procfile_fields = ("lifecycle_post_start", "lifecycle_pre_stop", "tags", "limits",
-                       "typed_values", "healthcheck", "termination_grace_period")
-    all_diff_fields = ("values", "registry") + procfile_fields
+    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=dict, blank=True)
-    typed_values = models.JSONField(default=dict, blank=True)
-    lifecycle_post_start = models.JSONField(default=dict, blank=True)
-    lifecycle_pre_stop = models.JSONField(default=dict, blank=True)
+    values = models.JSONField(default=list, blank=True)
+    values_refs = 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)
 
@@ -43,148 +40,241 @@ def __str__(self):
 
     def previous(self):
         """
-        Return the previous Release to this one.
+        Return the previous Config to this one.
 
-        :return: the previous :class:`Release`, or None
+        :return: the previous :class:`Config`, or None
         """
         configs = self.app.config_set
         if self.pk:
             configs = configs.exclude(pk=self.pk)
 
         try:
-            # Get the Release previous to this one
-            prev_release = configs.latest()
-        except Release.DoesNotExist:
-            prev_release = None
-        return prev_release
-
-    def diff(self, config=None):
-        old_config = config if config else self.previous()
+            # Get the Config previous to this one
+            prev_config = configs.latest()
+        except Config.DoesNotExist:
+            prev_config = None
+        return prev_config
+
+    def envs(self, ptype):
+        envs = {}
+        # group env
+        groups = ['global']
+        groups.extend(self.values_refs.get(ptype, []))
+        for group in groups:
+            for value in self.values:
+                if group == value.get('group'):
+                    envs[value['name']] = value['value']
+        # ptype env, repetition will replace the previous env
+        for value in self.values:
+            if value.get('ptype') == ptype:
+                envs[value['name']] = value['value']
+        return envs
+
+    def diff(self, old_config):
+        def flat_values(values):
+            ptype_envs, group_envs = {}, {}
+            for value in values:
+                ptype, group = value.get('ptype'), value.get('group')
+                if ptype:
+                    ptype_envs[ptype] = ptype_envs.get(ptype, {})
+                    ptype_envs[ptype][value['name']] = value['value']
+                if group:
+                    group_envs[group] = group_envs.get(group, {})
+                    group_envs[group][value['name']] = value['value']
+            return ptype_envs, group_envs
+
+        old_config = old_config if old_config else self.previous()
+        # ptype field diff
         result = {}
-        for field in self.all_diff_fields:
-            result[field] = dict_diff(getattr(self, field), getattr(old_config, field))
+        for field in self.ptype_fields:
+            new_value = getattr(self, field)
+            old_value = getattr(old_config, field)
+            result[field] = dict_diff(new_value, old_value)
+        values_diff = {}
+        (new_ptype_envs, new_group_envs), (old_ptype_envs, old_group_envs) = (
+            flat_values(self.values), flat_values(old_config.values))
+        # diff ptype env
+        for ptype in set(new_ptype_envs.keys()).union(old_ptype_envs.keys()):
+            values_diff.update(
+                dict_diff(new_ptype_envs.get(ptype, {}), old_ptype_envs.get(ptype, {})))
+        # diff group env
+        for group in set(new_group_envs.keys()).union(old_group_envs.keys()):
+            values_diff.update(
+                dict_diff(new_group_envs.get(group, {}), old_group_envs.get(group, {})))
+        result["values"] = values_diff
         return result
 
-    def save(self, **kwargs):
+    def diff_ptypes(self, old_config, include_ptypes):
+        old_config = old_config if old_config else self.previous()
+        ptypes = set()
+        for ptype in include_ptypes:
+            for field in self.ptype_fields:
+                new_value = getattr(self, field).get(ptype, None)
+                old_value = getattr(old_config, field).get(ptype, None)
+                if (new_value or old_value) and new_value != old_value:
+                    ptypes.add(ptype)
+            new_env = self.envs(ptype)
+            old_env = old_config.envs(ptype)
+            if new_env != old_env:
+                ptypes.add(ptype)
+        return ptypes
+
+    def save(self, ignore_update_fields=None, *args, **kwargs):
         """merge the old config with the new"""
         try:
             # Get config from the latest available release
-            try:
-                previous_config = self.app.release_set.filter(failed=False).latest().config
-            except Release.DoesNotExist:
+            latest_releases = Release.latest(self.app)
+            if latest_releases:
+                previous_config = latest_releases.config
+            else:
                 # If that doesn't exist then fallback on app config
                 # usually means a totally new app
                 previous_config = self.app.config_set.latest()
-
-            for attr in ['tags', 'registry', 'values', 'lifecycle_post_start',
-                         'lifecycle_pre_stop', 'termination_grace_period']:
-                data = getattr(previous_config, attr, {}).copy()
-                new_data = getattr(self, attr, {}).copy()
-                self._merge_data(attr, data, new_data)
-                setattr(self, attr, data)
-            self._set_typed_values(previous_config)
-            self._set_limits(previous_config)
-            self._set_healthcheck(previous_config)
-            self._set_registry()
-            self._set_tags(previous_config)
+            for field in self.allof_fields:
+                if ignore_update_fields is None or field not in ignore_update_fields:
+                    self.merge_field(field, previous_config)
         except Config.DoesNotExist:
-            self._set_tags({'tags': {}})
+            self._update_tags(previous_config={'tags': {}})
+        return super(Config, self).save(*args, **kwargs)
 
-        return super(Config, self).save(**kwargs)
+    def merge_field(self, field, old_config, *args, **kwargs):
+        getattr(
+            self,
+            "_update_%s" % field,
+            partial(self._update_field, field)
+        )(old_config, *args, **kwargs)
 
-    def _merge_data(self, field, data, new_data):
+    def _update_field(self, field, previous_config, replace_ptypes=[]):
+        data = {
+            k: v for k, v in getattr(previous_config, field, {}).copy().items()
+            if k not in replace_ptypes
+        }
+        new_data = getattr(self, field, {}).copy()
         # remove config keys if a null value is provided
         for key, value in new_data.items():
             if value is None:
                 # error if unsetting non-existing key
                 if key not in data:
-                    raise UnprocessableEntity('{} does not exist under {}'.format(key, field))  # noqa
+                    raise UnprocessableEntity(
+                        '{} does not exist under {}'.format(key, field))
                 data.pop(key)
             else:
-                data[key] = value
-        return data
-
-    def _set_registry(self):
-        # lower case all registry options for consistency
-        self.registry = {key.lower(): value for key, value in self.registry.copy().items()}
-
-        # PORT must be set if private registry is being used
-        if self.registry and self.values.get('PORT', None) is None:
-            # only thing that can get past post_save in the views
-            raise DryccException(
-                'PORT needs to be set in the config '
-                'when using a private registry')
-
-    def _set_tags(self, previous_config):
-        """verify the tags exist on any nodes as labels"""
-        if not self.tags:
-            if settings.DRYCC_DEFAULT_CONFIG_TAGS:
-                try:
-                    tags = json.loads(settings.DRYCC_DEFAULT_CONFIG_TAGS)
-                    self.tags = tags
-                except json.JSONDecodeError as e:
-                    logger.exception(e)
-                    return
-            else:
-                return
-
-        # Get all nodes with label selectors
-        nodes = self.scheduler().node.get(labels=self.tags).json()
-        if nodes['items']:
-            return
+                data[key] = self._merge_data(field, data.get(key, {}), value)
+        setattr(self, field, data)
 
-        labels = ['{}={}'.format(key, value) for key, value in self.tags.items()]
-        message = 'No nodes matched the provided labels: {}'.format(', '.join(labels))
+    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 and item.get('group') not in replace_groups
+        ]
+        new_data = getattr(self, 'values', []).copy()
+        for new_item in new_data:
+            added = True
+            for index, item in enumerate(data):
+                if (
+                    item['name'] == new_item['name'] and
+                    item.get('ptype') == new_item.get('ptype') and
+                    item.get('group') == new_item.get('group')
+                ):
+                    data.pop(index)
+                    if not new_item['value']:
+                        added = False
+                    else:  # force to string
+                        new_item['value'] = str(new_item['value'])
+                    break
+            if added and new_item['value'] is not None:
+                data.append(new_item)
+        setattr(self, 'values', data)
 
-        # Find out if there are any other tags around
-        old_tags = getattr(previous_config, 'tags')
-        if old_tags:
-            old = ['{}={}'.format(key, value) for key, value in old_tags.items()]
-            new = set(labels) - set(old)
-            if new:
-                message += ' - Addition of {} is the cause'.format(', '.join(new))
-
-        raise DryccException(message)
-
-    def _set_limits(self, previous_config):
-        data = getattr(previous_config, 'limits', {}).copy()
-        new_data = getattr(self, 'limits', {}).copy()
-        # check procfile
-        for key, value in new_data.items():
-            if value is None:
-                if key in self.app.procfile_types:
+    def _update_values_refs(self, previous_config, replace_ptypes=[]):
+        data = {
+            k: v for k, v in getattr(previous_config, 'values_refs', {}).copy().items()
+            if k not in replace_ptypes
+        }
+        new_data = getattr(self, 'values_refs', {}).copy()
+        # remove config keys if a null value is provided
+        for ptype, values in new_data.items():
+            if not values:
+                # error if unsetting non-existing key
+                if ptype not in data:
                     raise UnprocessableEntity(
-                        "the %s has already been used and cannot be deleted" % key)
-        self._merge_data('limits', data, new_data)
-        setattr(self, 'limits', data)
+                        '{} does not exist under {}'.format(ptype, 'values_refs'))
+                data.pop(ptype)
+            else:
+                values_refs = data.get(ptype, [])
+                values_refs.extend(values)
+                data[ptype] = list(set(values_refs))
+        setattr(self, 'values_refs', data)
 
-    def _set_healthcheck(self, previous_config):
-        data = getattr(previous_config, 'healthcheck', {}).copy()
-        new_data = getattr(self, 'healthcheck', {}).copy()
+    def _merge_data(self, field, data, new_data):
         # remove config keys if a null value is provided
         for key, value in new_data.items():
             if value is None:
                 # error if unsetting non-existing key
                 if key not in data:
-                    raise UnprocessableEntity(
-                        '{} does not exist under {}'.format(key, 'healthcheck'))
+                    raise UnprocessableEntity('{} does not exist under {}'.format(key, field))  # noqa
                 data.pop(key)
             else:
-                data[key] = self._merge_data('healthcheck', data.get(key, {}), value)
-        setattr(self, 'healthcheck', data)
+                data[key] = value
+        return data
 
-    def _set_typed_values(self, previous_config):
-        data = getattr(previous_config, 'typed_values', {}).copy()
-        new_data = getattr(self, 'typed_values', {}).copy()
+    def _update_tags(self, previous_config, replace_ptypes=[]):
+        """verify the tags exist on any nodes as labels"""
+        data = {
+            k: v for k, v in getattr(previous_config, 'tags', {}).copy().items()
+            if k not in replace_ptypes
+        }
+        new_data = getattr(self, 'tags', {}).copy()
         # remove config keys if a null value is provided
-        for procfile_type, values in new_data.items():
+        for ptype, values in new_data.items():
             if not values:
                 # error if unsetting non-existing key
-                if procfile_type not in data:
+                if ptype not in data:
                     raise UnprocessableEntity(
-                        '{} does not exist under {}'.format(procfile_type, 'typed_values'))
-                data.pop(procfile_type)
+                        '{} does not exist under {}'.format(ptype, 'tags'))
+                data.pop(ptype)
             else:
-                data[procfile_type] = self._merge_data(
-                    'typed_values', data.get(procfile_type, {}), values)
-        setattr(self, 'typed_values', data)
+                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
+                    old_tags = previous_config.tags.get(ptype, {})
+                    if old_tags:
+                        old = ['{}={}'.format(key, value) for key, value in old_tags.items()]
+                        new = set(labels) - set(old)
+                        if new:
+                            message += ' - Addition of {} is the cause'.format(', '.join(new))
+                    raise DryccException(message)
+                data[ptype] = self._merge_data(
+                    'tags', data.get(ptype, {}), values)
+        setattr(self, 'tags', data)
+
+    def _update_limits(self, previous_config, replace_ptypes=[]):
+        data = {
+            k: v for k, v in getattr(previous_config, 'limits', {}).copy().items()
+            if k not in replace_ptypes
+        }
+        new_data = getattr(self, 'limits', {}).copy()
+        # check procfile
+        for ptype, value in new_data.items():
+            if value is None:
+                if ptype in self.app.ptypes:
+                    raise UnprocessableEntity(
+                        "the %s has already been used and cannot be deleted" % ptype)
+        self._merge_data('limits', data, new_data)
+        setattr(self, 'limits', data)
+
+    def _update_termination_grace_period(self, previous_config, replace_ptypes=[]):
+        data = {
+            k: v for k, v in getattr(previous_config, 'termination_grace_period', {}).copy().items()  # noqa
+            if k not in replace_ptypes
+        }
+        new_data = getattr(self, 'termination_grace_period', {}).copy()
+        # check procfile
+        for ptype, value in new_data.items():
+            if value is None:
+                if ptype in self.app.ptypes:
+                    raise UnprocessableEntity(
+                        "the %s has already been used and cannot be deleted" % ptype)
+        self._merge_data('termination_grace_period', data, new_data)
+        setattr(self, 'termination_grace_period', data)
diff --git a/rootfs/api/models/domain.py b/rootfs/api/models/domain.py
index 1d2def5fc..a1caff610 100644
--- a/rootfs/api/models/domain.py
+++ b/rootfs/api/models/domain.py
@@ -1,13 +1,12 @@
 from django.db import models
 from django.db import transaction
 from django.contrib.auth import get_user_model
-from .base import AuditedModel
+from .base import AuditedModel, PTYPE_MAX_LENGTH
 
 User = get_user_model()
 
 
 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,
@@ -21,7 +20,7 @@ class Domain(AuditedModel):
         blank=True,
         null=True
     )
-    procfile_type = models.TextField()
+    ptype = models.CharField(max_length=PTYPE_MAX_LENGTH)
 
     class Meta:
         ordering = ['domain', 'certificate']
diff --git a/rootfs/api/models/gateway.py b/rootfs/api/models/gateway.py
index 4703210b3..c4e68c323 100644
--- a/rootfs/api/models/gateway.py
+++ b/rootfs/api/models/gateway.py
@@ -1,29 +1,42 @@
-import re
 import logging
+import hashlib
+import threading
 from django.db import models
 from django.conf import settings
-from django.shortcuts import get_object_or_404
-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
+from .base import AuditedModel, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT
+
 
-User = get_user_model()
 logger = logging.getLogger(__name__)
-DEFAULT_HTTP_PORT = 80
-DEFAULT_HTTPS_PORT = 443
 
-HOSTNAME_PROTOCOLS = ("TLS", "HTTP", "HTTPS")
+TLS_PROTOCOLS = ("HTTPS", "TLS")
+HOSTNAME_PROTOCOLS = TLS_PROTOCOLS + ("HTTP", )
 
 
 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):
+        """Logs a message in the context of this service.
+
+        This prefixes log messages with an application "tag" that the customized
+        drycc-logspout will be on the lookout for.  When it's seen, the message-- usually
+        an application event of some sort like releasing or scaling, will be considered
+        as "belonging" to the application instead of the controller and will be handled
+        accordingly.
+        """
+        send_app_log.delay(self.app.id, message, level)
+        logger.log(level, "[{}]: {}".format(self.app.id, message))
+
     def add(self, port, protocol):
         # check port
         if not self._check_port(port, protocol):
@@ -41,64 +54,66 @@ def remove(self, port, protocol):
         self.ports = ports
         return True, ""
 
+    @property
+    def addresses(self):
+        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", [])
+        return addresses
+
     @property
     def listeners(self):
-        listeners = []
         auto_tls = self.app.tls_set.latest().certs_auto_enabled
-        domains = list(self._get_tls_domain(auto_tls))
+        listeners = []
+        domains = list(self.app.domain_set.all())
         for item in self.ports:
             port, protocol = item["port"], item["protocol"]
             if item["protocol"] in HOSTNAME_PROTOCOLS:
                 for domain in domains:
                     listener = {
                         "allowedRoutes": {"namespaces": {"from": "All"}},
-                        "name": self._get_listener_name(port, protocol, domains.index(domain)),
+                        "name": self._get_listener_name(port, protocol, domains.index(domain) + 1),
                         "port": port,
                         "hostname": domain.domain,
                         "protocol": protocol,
                     }
                     secret_name = f"{self.app.id}-auto-tls" if auto_tls else (
-                        domain.certificate.name if domain.certificate else None)
-                    if secret_name:
+                        domain.certificate.certname if domain.certificate else None)
+                    if secret_name and protocol in TLS_PROTOCOLS:
                         listener["tls"] = {
                             "certificateRefs": [{"kind": "Secret", "name": secret_name}]}
                     listeners.append(listener)
-            listeners.append({
-                "allowedRoutes": {"namespaces": {"from": "All"}},
-                "name": self._get_listener_name(port, protocol, 0),
-                "port": port,
-                "protocol": protocol,
-            })
+            if protocol not in TLS_PROTOCOLS:
+                listeners.append({
+                    "allowedRoutes": {"namespaces": {"from": "All"}},
+                    "name": self._get_listener_name(port, protocol, 0),
+                    "port": port,
+                    "protocol": protocol,
+                })
         return listeners
 
-    @property
-    def addresses(self):
-        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", [])
-        return addresses
-
     def refresh_to_k8s(self):
+        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()
-                if len(self.listeners) > 0:
-                    self.scheduler().gateways.patch(self.app.id, self.name, **{
-                        "listeners": self.listeners,
-                        "gateway_class": settings.GATEWAY_CLASS,
-                        "version": data["metadata"]["resourceVersion"],
-                    })
+                data = self.scheduler.gateways.get(self.app.id, self.name).json()
+                if len(kwargs["listeners"]) > 0:
+                    kwargs["version"] = data["metadata"]["resourceVersion"]
+                    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(self.listeners) > 0:
-                    self.scheduler().gateways.create(self.app.id, self.name, **{
-                        "listeners": self.listeners,
-                        "gateway_class": settings.GATEWAY_CLASS,
-                    })
+                if len(kwargs["listeners"]) > 0:
+                    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:
@@ -112,8 +127,7 @@ def change_default_tls(self):
                            models.Q(certificate__isnull=False)).exists()
         if tls_enabled:
             return self.add(DEFAULT_HTTPS_PORT, "HTTPS")[0]
-        else:
-            return self.remove(DEFAULT_HTTPS_PORT, "HTTPS")[0]
+        return self.remove(DEFAULT_HTTPS_PORT, "HTTPS")[0]
 
     def save(self, *args, **kwargs):
         self.change_default_tls()
@@ -122,14 +136,29 @@ 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:
-            logger.log(
-                msg='Kubernetes gateway cannot be deleted: {}'.format(self.name),
+            self.log(
+                'Kubernetes gateway cannot be deleted: {}'.format(self.name),
                 level=logging.ERROR,
             )
         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:
@@ -138,12 +167,6 @@ def _check_port(self, port, protocol):
                     return False
         return True
 
-    def _get_tls_domain(self, auto_tls):
-        domains = self.app.domain_set.all()
-        if not auto_tls:
-            domains = domains.exclude(certificate=None)
-        return domains
-
     def _get_listener_name(self, port, protocol, index):
         if protocol in ("TCP", "TLS", "HTTP"):
             protocol = "TCP"
@@ -160,6 +183,7 @@ class Meta:
 
 
 class Route(AuditedModel):
+    CACHE = threading.local()
     PROTOCOLS_CHOICES = {
         "TLSRoute": ("TCP", ),
         "TCPRoute": ("TCP", ),
@@ -169,15 +193,24 @@ 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)
-    port = models.PositiveIntegerField()
     rules = models.JSONField(default=list)
     routable = models.BooleanField(default=True)
     parent_refs = models.JSONField(default=list)
-    procfile_type = models.TextField()
+
+    @property
+    def services(self):
+        key = f"{self.app.id}_{self.name}"
+        if not hasattr(self.CACHE, key):
+            service_names = set()
+            for rule in self.rules:
+                for backend in rule['backendRefs']:
+                    service_names.add(backend['name'])
+            setattr(self.CACHE, key,
+                    [s for s in self.app.service_set.all() if s.name in service_names])
+        return getattr(self.CACHE, key)
 
     @property
     def protocols(self):
@@ -188,69 +221,77 @@ def protocols(self):
     @property
     def hostnames(self):
         return [domain.domain for domain in self.app.domain_set.filter(
-                procfile_type=self.procfile_type)]
+                ptype__in=[s.ptype for s in self.services])]
 
     @property
-    def default_rules(self):
-        service = get_object_or_404(self.app.service_set, procfile_type=self.procfile_type)
-        stable_backend_refs, canary_backend_refs = [], []
-        for item in service.ports:
-            if item["port"] == self.port:
-                stable_backend = {
-                    "kind": "Service",
-                    "name": str(service),
-                    "port": item["port"],
-                    "weight": 100,
-                }
-                canary_backend = {
-                    "kind": "Service",
-                    "name": "%s-canary" % str(service),
-                    "port": item["port"],
-                    "weight": 0,
-                }
-                stable_backend_refs.append(stable_backend)
-                canary_backend_refs.append(stable_backend)
-                canary_backend_refs.append(canary_backend)
-        return {
-            "stable": [{"backendRefs": stable_backend_refs}],
-            "canary": [{"backendRefs": canary_backend_refs}],
-        }
+    def cleaned_rules(self):
+        services, rules = self.services, []
+        for rule in self.rules:
+            backend_refs = []
+            for backend_ref in rule["backendRefs"]:
+                for service in services:
+                    ports = [item["port"] for item in service.ports]
+                    if backend_ref["port"] in ports and backend_ref["name"] == service.name:
+                        backend_refs.append(backend_ref)
+            if backend_refs:
+                rule['backendRefs'] = backend_refs
+                rules.append(rule)
+        return rules
 
     @property
-    def current_rules(self):
-        app_settings = self.app.appsettings_set.latest()
-        if self.procfile_type in app_settings.canaries:
-            return self.rules["canary"]
-        return self.rules["stable"]
-
-    def check_rules(self):
-        service = self.app.service_set.filter(
-            procfile_type=self.procfile_type).first()
-        ports = [item["port"] for item in service.ports]
-        for rules in self.rules.values():
-            for rule in rules:
-                for backend_ref in rule["backendRefs"]:
-                    port = backend_ref["port"]
-                    name = re.sub(r'(.*)-canary', r'\1', backend_ref["name"])
-                    if port not in ports or name != str(service):
-                        return False, {"detail": "backendRefs associated with incorrect service"}
+    def tls_force_hostnames(self):
+        tls = self.app.tls_set.latest()
+        q = Q(ptype__in=[s.ptype for s in self.services])
+        if not tls.certs_auto_enabled:
+            q &= Q(certificate__isnull=False)
+        domains = self.app.domain_set.filter(q)
+        return [domain.domain for domain in domains]
+
+    def check_rules(self, rules):
+        for rule in rules:
+            for backend_ref in rule["backendRefs"]:
+                kind = backend_ref.get("kind", "Service")
+                if kind != "Service":
+                    return False, {"detail": "BackendRef only supports service kind"}
+                has_service = False
+                for service in self.services:
+                    ports = [item["port"] for item in service.ports]
+                    if backend_ref["port"] in ports and backend_ref["name"] == service.name:
+                        has_service = True
+                        break
+                if not has_service:
+                    msg = f"service {backend_ref['name']}:{backend_ref['port']} does not exist"
+                    return False, {"detail": msg}
         return True, ""
 
+    def log(self, message, level=logging.INFO):
+        """Logs a message in the context of this service.
+
+        This prefixes log messages with an application "tag" that the customized
+        drycc-logspout will be on the lookout for.  When it's seen, the message-- usually
+        an application event of some sort like releasing or scaling, will be considered
+        as "belonging" to the application instead of the controller and will be handled
+        accordingly.
+        """
+        send_app_log.delay(self.app.id, message, level)
+        logger.log(level, "[{}]: {}".format(self.app.id, message))
+
     def refresh_to_k8s(self):
         if self.routable:
             parent_refs, http_parent_refs = self._get_all_parent_refs()
             tls = self.app.tls_set.latest()
-            if tls.https_enforced and self.kind == "HTTPRoute":
+            # requestRedirect only when has tls or certs
+            if tls.https_enforced and self.kind == "HTTPRoute" and self.tls_force_hostnames:
                 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.current_rules, 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:
@@ -284,19 +325,22 @@ def detach(self, gateway_name, port):
 
     def save(self, *args, **kwargs):
         self.change_default_tls()
-        ok, msg = self.check_rules()
-        if not ok:
-            raise ValueError(msg)
+        cleaned_rules = self.cleaned_rules
+        if not cleaned_rules:
+            msg = f"route {self.name} no available backend"
+            self.log(msg, level=logging.ERROR)
+            raise Http404(msg)
+        self.rules = cleaned_rules
         super().save(*args, **kwargs)
         self.refresh_to_k8s()
 
     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:
-            logger.log(
-                msg='Kubernetes {} cannot be deleted: {}'.format(self.kind.lower(), self.name),
+            self.log(
+                'Kubernetes {} cannot be deleted: {}'.format(self.kind.lower(), self.name),
                 level=logging.ERROR,
             )
         return super().delete(*args, **kwargs)
@@ -327,49 +371,46 @@ def _check_parent(self, gateway_name, port):
         return True, ""
 
     def _refresh_to_k8s(self, rules, parent_refs):
+        manifest = {
+            "rules": rules,
+            "hostnames": self.hostnames,
+            "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()
-                k8s_route.patch(self.app.id, self.name, **{
-                    "rules": rules,
-                    "hostnames": self.hostnames,
-                    "parent_refs": parent_refs,
-                    "version": data["metadata"]["resourceVersion"],
-                })
+                manifest.update({"version": data["metadata"]["resourceVersion"]})
+                k8s_route.patch(self.app.id, self.name, **manifest)
             except KubeException:
-                k8s_route.create(self.app.id, self.name, **{
-                    "rules": rules,
-                    "hostnames": self.hostnames,
-                    "parent_refs": parent_refs,
-                })
+                k8s_route.create(self.app.id, self.name, **manifest)
         except KubeException as e:
             raise ServiceUnavailable(
                 f'Kubernetes {self.kind.lower()} could not be created') from e
 
     def _https_enforced_to_k8s(self, parent_refs):
-        rules = {
-            "filters": [{
-                "type": "RequestRedirect",
-                "requestRedirect": {
-                    "port": DEFAULT_HTTPS_PORT, "scheme": "https", "statusCode": 301
-                }
-            }]
+        manifest = {
+            "rules": [{
+                "filters": [{
+                    "type": "RequestRedirect",
+                    "requestRedirect": {
+                        "port": DEFAULT_HTTPS_PORT, "scheme": "https", "statusCode": 301
+                    }
+                }]
+            }],
+            "hostnames": self.tls_force_hostnames,
+            "parent_refs": parent_refs,
         }
         try:
             try:
-                data = self.scheduler().httproute.get(
+                data = self.scheduler.httproute.get(
                     self.app.id, self._https_redirect_name).json()
-                self.scheduler().httproute.patch(self.app.id, self._https_redirect_name, **{
-                    "rules": rules,
-                    "parent_refs": parent_refs,
-                    "version": data["metadata"]["resourceVersion"],
-                })
+                manifest.update({"version": data["metadata"]["resourceVersion"]})
+                self.scheduler.httproute.patch(
+                    self.app.id, self._https_redirect_name, **manifest)
             except KubeException:
-                self.scheduler().httproute.create(self.app.id, self._https_redirect_name, **{
-                    "rules": rules,
-                    "parent_refs": parent_refs,
-                })
+                self.scheduler.httproute.create(
+                    self.app.id, self._https_redirect_name, **manifest)
         except KubeException as e:
             raise ServiceUnavailable(
                 f'Kubernetes {self.kind.lower()} could not be created') from e
@@ -379,14 +420,16 @@ def _get_all_parent_refs(self):
         for gateway in self.app.gateway_set.filter(
                 name__in=[item["name"] for item in self.parent_refs]):
             gateways[gateway.name] = gateway
-        parent_refs, http_parent_refs = [], []
+        hostnames, parent_refs, http_parent_refs = self.hostnames, [], []
         for item in self.parent_refs:
             gateway_name, port = item["name"], item["port"]
             if gateway_name not in gateways:
                 continue
             gateway = gateways[gateway_name]
             for listener in gateway.listeners:
-                if listener["port"] == port and listener["protocol"] in self.protocols:
+                hostname = listener.get('hostname', None)
+                if listener["port"] == port and listener["protocol"] in self.protocols and (
+                        hostname is None or hostname in hostnames):
                     parent_ref = {
                         "group": "gateway.networking.k8s.io",
                         "kind": "Gateway",
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 7727bf8cd..a72eda1a4 100644
--- a/rootfs/api/models/limit.py
+++ b/rootfs/api/models/limit.py
@@ -115,12 +115,18 @@ class LimitPlan(AuditedModel):
     requests = models.JSONField(default=dict)
     annotations = models.JSONField(default=dict)
     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 03b30c73c..8a9f8c2f8 100644
--- a/rootfs/api/models/release.py
+++ b/rootfs/api/models/release.py
@@ -1,13 +1,20 @@
 import logging
 
+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 api.tasks import run_pipeline
+from django.db.models import F, Func, Value, JSONField
+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
-from .base import UuidAuditedModel
+
+from ..utils import DeployLock, dict_diff
+
+from .base import UuidAuditedModel, PTYPE_WEB
+from .appsettings import AppSettings
 
 
 User = get_user_model()
@@ -25,14 +32,14 @@ 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()
     summary = models.TextField(blank=True, null=True)
     failed = models.BooleanField(default=False)
-    canary = models.BooleanField(default=False)
     exception = models.TextField(blank=True, null=True)
+    conditions = models.JSONField(default=list)
+    deployed_ptypes = models.JSONField(default=list)
 
     config = models.ForeignKey('Config', on_delete=models.CASCADE)
     build = models.ForeignKey('Build', null=True, on_delete=models.CASCADE)
@@ -41,72 +48,79 @@ class Meta:
         get_latest_by = 'created'
         ordering = ['-created']
         unique_together = (('app', 'version'),)
+        indexes = [
+            models.Index(fields=['app', 'failed', 'state']),
+        ]
 
     def __str__(self):
-        return "{0}-v{1}".format(self.app.id, self.version)
+        return "{0}-{1}".format(self.app.id, self.version_name)
 
     @property
-    def procfile_types(self):
+    def ptypes(self):
         if self.build is not None:
-            return self.build.procfile_types
-        return []
-
-    def get_run_image(self):
-        """
-        In the run phase of dryccfile
-        Return the kubernetes "container image" to be sent off to the scheduler.
-        """
-        image = self.build.dryccfile.get('run', {}).get(
-            'image', self.build.get_image('run'))
-        return self.build.get_image(image, default_image=image)
-
-    def get_run_args(self):
-        """
-        In the run phase of dryccfile
-        Return the kubernetes "container arguments" to be sent off to the scheduler.
-        """
-        return self.build.dryccfile.get('run', {}).get('args', [])
+            return self.build.ptypes
+        return [PTYPE_WEB]
 
-    def get_run_command(self):
-        """
-        In the run phase of dryccfile
-        Return the kubernetes "container command" to be sent off to the scheduler.
-        """
-        return self.build.dryccfile.get('run', {}).get('command', [])
+    @property
+    def version_name(self):
+        return f'v{self.version}'
+
+    def get_runners(self, ptypes):
+        results = []
+        ptypes = self.ptypes if not ptypes else ptypes
+        for pipeline in self.build.get_pipelines(ptypes):
+            if 'run' in pipeline:
+                image = pipeline['run'].get('image', self.build.get_image(pipeline['ptype']))
+                results.append({
+                    'ptype': pipeline['ptype'],
+                    'image': self.build.get_image(image, default_image=image),
+                    'args': pipeline['run'].get('args', []),
+                    'command': pipeline['run'].get('command', []),
+                    'timeout': pipeline['run'].get('timeout', settings.DRYCC_PILELINE_RUN_TIMEOUT),
+                })
+        return results
+
+    def add_condition(self, **kwargs):
+        if "created" not in kwargs:
+            kwargs["created"] = datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT)
+        type(self).objects.filter(pk=self.pk).update(
+            conditions=Func(
+                F("conditions"),
+                Value(["0"]),
+                Value(kwargs, JSONField()),
+                function="jsonb_insert",
+            )
+        )
 
-    def get_deploy_image(self, container_type):
+    def get_deploy_image(self, ptype):
         """
-        In the deploy phase of dryccfile
         Return the kubernetes "container image" to be sent off to the scheduler.
         """
-        image = self.build.dryccfile.get('deploy', {}).get(container_type, {}).get(
-            'image', self.build.get_image(container_type))
+        image = self.build.get_pipeline(ptype).get('deploy', {}).get(
+            'image', self.build.get_image(ptype))
         return self.build.get_image(image, default_image=image)
 
-    def get_deploy_args(self, container_type):
+    def get_deploy_args(self, ptype):
         """
-        In the deploy phase of dryccfile
         Return the kubernetes "container arguments" to be sent off to the scheduler.
         """
         if self.build is not None:
             if self.build.dryccfile:
-                return self.build.dryccfile['deploy'].get(container_type, {}).get('args', [])
+                return self.build.get_pipeline(ptype).get('deploy', {}).get('args', [])
             else:
                 # dockerfile or container image
                 if self.build.dockerfile or not self.build.sha:
                     # has profile
-                    if self.build.procfile and container_type in self.build.procfile:
-                        args = self.build.procfile[container_type]
+                    if self.build.procfile and ptype in self.build.procfile:
+                        args = self.build.procfile[ptype]
                         return args.split()
         return []
 
-    def get_deploy_command(self, container_type):
+    def get_deploy_command(self, ptype):
         """
-        In the deploy phase of dryccfile
         Return the kubernetes "container command" to be sent off to the scheduler.
         """
-        return self.build.dryccfile.get(
-            'deploy', {}).get(container_type, {}).get('command', [])
+        return self.build.get_pipeline(ptype).get('deploy', {}).get('command', [])
 
     def log(self, message, level=logging.INFO):
         """Logs a message in the context of this application.
@@ -117,9 +131,10 @@ 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, canary=False):
+    def new(self, user, config, build, summary=None):
         """
         Create a new application release using the provided Build and Config
         on behalf of a user.
@@ -130,77 +145,94 @@ def new(self, user, config, build, summary=None, canary=False):
         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, canary=canary
+            app=self.app, config=config, build=build, version=new_version, summary=summary
         )
 
-    def get_port(self):
+    def get_port(self, ptype):
         """
         Get application port for a given release. If pulling from private registry
         then use default port or read from ENV var, otherwise attempt to pull from
         the container image
         """
+        return int(self.config.envs(ptype).get('PORT', DEFAULT_CONTAINER_PORT))
+
+    def diff_ptypes(self, ptypes):
+        def _get_full_deploy(release, ptypes):
+            deploy = {}
+            for ptype in ptypes:
+                deploy[ptype] = {
+                    'image': release.get_deploy_image(ptype),
+                    'args': release.get_deploy_args(ptype),
+                    'command': release.get_deploy_command(ptype),
+                }
+            return deploy
+
+        pre_release = self.previous()
+        if pre_release is None:
+            return ptypes
+        changed_ptypes = set(self.config.diff_ptypes(pre_release.config, ptypes))
+        if pre_release.build and self.build:
+            deploy = _get_full_deploy(self, ptypes)
+            pre_deploy = _get_full_deploy(pre_release, ptypes)
+            for value in dict_diff(deploy, pre_deploy).values():
+                changed_ptypes = changed_ptypes.union(value.keys())
+        return list(changed_ptypes)
+
+    def deploy(self, ptypes=None, force_deploy=False):
+        if not self.build:
+            raise DryccException("there are no builds available for deploy")
+        ptypes = set(ptypes).intersection(self.ptypes) if ptypes else self.ptypes
+        if not ptypes:
+            raise DryccException(
+                f'skip deploy, ptypes are not within the optional range: {self.ptypes}')
+        # change deployed_ptypes lock
+        msg = 'there is an executing pipeline, please wait or force deploy'
+        lock = self.app.lock()
         try:
-            envs = self.config.values
-            creds = self.get_registry_auth()
-
-            if self.build.type == "buildpack":
-                self.log(
-                    'buildpack type detected. Defaulting to $PORT %s' % DEFAULT_CONTAINER_PORT)
-                return DEFAULT_CONTAINER_PORT
-
-            # application has registry auth - $PORT is required
-            if (creds is not None) or (settings.REGISTRY_LOCATION != 'on-cluster'):
-                if envs.get('PORT', None) is None:
-                    if not self.app.appsettings_set.latest().routable:
-                        return None
-                    raise DryccException(
-                        'PORT needs to be set in the application config '
-                        'when using a private registry'
-                    )
-
-                # User provided PORT
-                return int(envs.get('PORT'))
-
-            # If the user provides PORT
-            return int(envs.get('PORT', DEFAULT_CONTAINER_PORT))
-
-        except Exception as e:
-            raise DryccException(str(e)) from e
+            if lock.acquire() or force_deploy:
+                deployed_ptypes = list(set(self.deployed_ptypes).union(ptypes))
+                if deployed_ptypes:
+                    type(self).objects.filter(pk=self.pk).update(deployed_ptypes=deployed_ptypes)
+            else:
+                raise DryccException(msg)
+        finally:
+            lock.release()
+        # diff ptypes
+        if not force_deploy and self.app.appsettings_set.latest().autodeploy:
+            ptypes = self.diff_ptypes(ptypes)
+        # deploy lock
+        lock = DeployLock(self.app.pk)
+        if not lock.acquire(ptypes, force=force_deploy):
+            raise DryccException(f"{msg}: {lock.locked(ptypes)}")
+        run_pipeline.delay(self, ptypes, force_deploy)
+
+    @classmethod
+    def latest(cls, app, state=None, exclude_pk=None):
+        releases = app.release_set
+        if exclude_pk:
+            releases = releases.exclude(pk=exclude_pk)
+        q = Q(failed=False, state="succeed") if state else Q(failed=False)
+        try:
+            app_settings = app.appsettings_set.latest()
+            if app_settings.autorollback is False:
+                q = Q()
+        except AppSettings.DoesNotExist:
+            pass
+        try:
+            # Get the Release previous to this one
+            release = releases.filter(q).latest()
+        except Release.DoesNotExist:
+            release = None
+        return release
 
-    def get_registry_auth(self):
-        """
-        Gather login information for private registry if needed
-        """
-        auth = None
-        registry = self.config.registry
-        if registry.get('username', None):
-            auth = {
-                'username': registry.get('username', None),
-                'password': registry.get('password', None),
-                'email': self.owner.email
-            }
-
-        return auth
-
-    def previous(self):
+    def previous(self, state="succeed"):
         """
         Return the previous Release to this one.
-
         :return: the previous :class:`Release`, or None
         """
-        releases = self.app.release_set
-        if self.pk:
-            releases = releases.exclude(pk=self.pk)
+        return self.latest(self.app, state, self.pk)
 
-        try:
-            # Get the Release previous to this one
-            prev_release = releases.filter(failed=False).latest()
-        except Release.DoesNotExist:
-            prev_release = None
-        return prev_release
-
-    def rollback(self, user, version=None):
+    def rollback(self, user, ptypes=None, version=None):
         try:
             # if no version is provided then grab version from object
             version = (self.version - 1) if version is None else int(version)
@@ -209,22 +241,18 @@ def rollback(self, user, version=None):
                 raise DryccException('version cannot be below 0')
             elif version == 1:
                 raise DryccException('Cannot roll back to initial release.')
-
             prev = self.app.release_set.get(version=version)
             if prev.failed:
                 raise DryccException('Cannot roll back to failed release.')
-            app_settings = self.app.appsettings_set.latest()
             latest_version = self.app.release_set.latest().version
             new_release = self.new(
                 user,
                 build=prev.build,
                 config=prev.config,
                 summary="{} rolled back to v{}".format(user, version),
-                canary=len(app_settings.canaries) > 0,
             )
-
             if self.build is not None:
-                run_pipeline.delay(new_release, force_deploy=True)
+                new_release.deploy(ptypes, force_deploy=True)
             return new_release
         except Exception as e:
             # check if the exception is during create or publish
@@ -233,13 +261,17 @@ def rollback(self, user, version=None):
                 new_release = self.app.release_set.latest()
                 new_release.state = "crashed"
                 new_release.failed = True
-                new_release.summary = "{} performed roll back to a release that failed".format(self.owner)  # noqa
+                if new_release.summary:
+                    new_release.summary += " "
+                new_release.summary += "{} performed roll back to a release that failed".format(
+                    user)
                 # Get the exception that has occured
                 new_release.exception = "error: {}".format(str(e))
-                new_release.save()
+                # avoid overwriting other fields
+                new_release.save(update_fields=["state", "failed", "summary", "exception"])
             raise DryccException(str(e)) from e
 
-    def cleanup_old(self, procfile_types=None):
+    def clean(self, ptypes=None):
         """
         Cleanup any old resources from Kubernetes
 
@@ -247,67 +279,68 @@ def cleanup_old(self, procfile_types=None):
         Secrets no longer tied to any ReplicaSet
         Stray pods no longer relevant to the latest release
         """
-        latest_version = 'v{}'.format(self.version)
         self.log(
-            'Cleaning up RSs for releases older than {} (latest)'.format(latest_version),
+            'Cleaning up RSs for releases older than {} (latest)'.format(self.version_name),
             level=logging.DEBUG
         )
         # base labels
         labels = {'heritage': 'drycc'}
-        if procfile_types:
-            labels['type__in'] = procfile_types
+        if ptypes is not 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:
-            current_version = replica_set['metadata']['labels']['version']
-            # skip the latest release
-            if current_version == latest_version:
+            current_version_name = replica_set['metadata']['labels']['version']
+            # skip the later release
+            current_version = int(current_version_name[1:])
+            if current_version >= self.version:
                 continue
 
             # aggregate versions together to removal all at once
-            if current_version not in replica_sets_removal:
-                replica_sets_removal.append(current_version)
+            if current_version_name not in replica_sets_removal:
+                replica_sets_removal.append(current_version_name)
 
         if replica_sets_removal:
             self.log(
-                'Found the following versions to cleanup: {}'.format(', '.join(replica_sets_removal)),  # noqa
+                'Found the following versions to cleanup: {}'.format(
+                    ', '.join(replica_sets_removal)),
                 level=logging.DEBUG
             )
         # this is RC related
-        for version in replica_sets_removal:
-            self._delete_release_in_scheduler(self.app.id, procfile_types, version)
+        for version_name in replica_sets_removal:
+            self._delete_release_in_scheduler(self.app.id, ptypes, version_name)
         # handle Deployments specific cleanups
-        self._cleanup_deployment_secrets_and_configs(self.app.id, procfile_types)
+        self._cleanup_deployment_secrets_and_configs(self.app.id, ptypes)
         # Remove stray pods
-        self._cleanup_stray_pods(self.app.id, procfile_types, latest_version)
+        self._cleanup_stray_pods(self.app.id, ptypes, self.version)
 
-    def _cleanup_stray_pods(self, namespace, procfile_types, latest_version):
+    def _cleanup_stray_pods(self, namespace, ptypes, latest_version):
         labels = {'heritage': 'drycc'}
-        if procfile_types:
-            labels['type__in'] = procfile_types
-        pods = self.scheduler().pod.get(namespace, labels=labels).json()['items']
+        if ptypes is not None:
+            labels['type__in'] = ptypes
+        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 = pod['metadata']['labels']['version']
-            # skip the latest release
-            if current_version == latest_version:
+            current_version = int(pod['metadata']['labels']['version'][1:])
+            # skip the later release
+            if current_version >= 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:
                     continue
 
-    def _cleanup_deployment_secrets_and_configs(self, namespace, procfile_types=None):
+    def _cleanup_deployment_secrets_and_configs(self, namespace, ptypes=None):
         """
         Clean up any environment secrets (and in the future ConfigMaps) that
         are tied to a release Deployments no longer track
@@ -318,19 +351,22 @@ def _cleanup_deployment_secrets_and_configs(self, namespace, procfile_types=None
         """
 
         # Find all ReplicaSets
-        versions = []
+        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:
+            replica_version = int(replicaset['metadata']['labels']['version'][1:])
+            # only remove the older releases secret
             if (
                 'version' not in replicaset['metadata']['labels'] or
-                replicaset['metadata']['labels']['version'] in versions
+                replica_version < self.version or
+                replicaset['metadata']['labels']['version'] in version_names
             ):
                 continue
 
-            versions.append(replicaset['metadata']['labels']['version'])
+            version_names.append(replicaset['metadata']['labels']['version'])
 
         # find all env secrets not owned by any replicaset
         labels = {
@@ -338,18 +374,18 @@ def _cleanup_deployment_secrets_and_configs(self, namespace, procfile_types=None
             'app': namespace,
             'class': 'env',
             # http://kubernetes.io/docs/user-guide/labels/#set-based-requirement
-            'version__notin': versions
+            'version__notin': version_names
         }
-        if procfile_types:
-            labels['type__in'] = procfile_types
+        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, procfile_types, version):
+    def _delete_release_in_scheduler(self, namespace, ptypes, version_name):
         """
         Deletes a specific release in k8s based on ReplicationController
 
@@ -359,21 +395,26 @@ def _delete_release_in_scheduler(self, namespace, procfile_types, version):
         labels = {
             'heritage': 'drycc',
             'app': namespace,
-            'version': version
+            'version': version_name
         }
-        if procfile_types:
-            labels['type__in'] = procfile_types
-        # see if the app config has deploy timeout preference, otherwise use global
-        timeout = self.config.values.get('DRYCC_DEPLOY_TIMEOUT', settings.DRYCC_DEPLOY_TIMEOUT)
-
-        replica_sets = self.scheduler().rs.get(namespace, labels=labels).json()['items']
+        if ptypes is not None:
+            labels['type__in'] = ptypes
+        replica_sets = self.scheduler.rs.get(namespace, labels=labels).json()['items']
         if not replica_sets:
             replica_sets = []
         for replica_set in replica_sets:
+            # see if the app config has deploy timeout preference, otherwise use global
+            ptype = replica_set['metadata'].get("labels", {}).get("type", PTYPE_WEB)
+            timeout = self.config.envs(ptype).get(
+                'DRYCC_DEPLOY_TIMEOUT', settings.DRYCC_DEPLOY_TIMEOUT)
             # Deployment takes care of this in the API, RS does not
             # Have the RS scale down pods and delete itself
-            self.scheduler().rs.scale(namespace, replica_set['metadata']['name'], 0, timeout)
-            self.scheduler().rs.delete(namespace, replica_set['metadata']['name'])
+            try:
+                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
 
     def save(self, *args, **kwargs):  # noqa
         if not self.summary:
@@ -384,25 +425,28 @@ 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 = []
                     for diff_type, values in diff.items():
-                        diff_list.append(f'{diff_type} {field} {", ".join(values.keys())}')
+                        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 2ffbdb32e..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,12 +189,13 @@ 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 = ""
-            message = resp.json().get("status", {}).get("conditions", [{}])[-1].\
-                get("message", "")
+            conditions = resp.json().get("status", {}).get("conditions")
+            message = conditions[-1].get("message", "") \
+                if conditions and isinstance(conditions[-1], dict) else ""
             return message
         except KubeException as e:
             logger.info("retrieve instance info error: {}".format(e))
@@ -204,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",
@@ -215,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', {})
@@ -239,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 21218dc93..6b1c2711f 100644
--- a/rootfs/api/models/service.py
+++ b/rootfs/api/models/service.py
@@ -1,12 +1,12 @@
 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
-from .base import AuditedModel
+from .base import AuditedModel, PTYPE_MAX_LENGTH
 
 
 User = get_user_model()
@@ -29,37 +29,52 @@
 
 
 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)])
-    canary = models.BooleanField(default=False)
-    procfile_type = models.TextField()
+    ptype = models.CharField(max_length=PTYPE_MAX_LENGTH)
 
     class Meta:
         get_latest_by = 'created'
-        unique_together = (('app', 'procfile_type'), )
+        unique_together = (('app', 'ptype'), )
         ordering = ['-created']
 
     def __str__(self):
-        return self._svc_name(False)
+        return self.name
+
+    @property
+    def name(self):
+        if self.ptype == 'web':
+            svc_name = self.app.id
+        else:
+            svc_name = "{}-{}".format(self.app.id, self.ptype)
+        return svc_name
 
     @property
     def domain(self):
-        return "{}.{}.svc.{}".format(
-            self._svc_name(False), self._namespace(), settings.KUBERNETES_CLUSTER_DOMAIN
-        )
+        return "{}.{}.svc".format(self.name, self.namespace)
+
+    @property
+    def namespace(self):
+        return self.app.id
+
+    @classmethod
+    def get(cls, app, name):
+        for service in cls.objects.filter(app=app):
+            if service.name == name:
+                return service
+        raise cls.DoesNotExist()
 
     def as_dict(self):
         return {
+            "name": self.name,
             "domain": self.domain,
             "ports": self.ports,
-            "canary": self.canary,
-            "procfile_type": self.procfile_type,
+            "ptype": self.ptype,
         }
 
     def port_name(self, port, protocol):
-        return "-".join([self.app.id, self.procfile_type, protocol, str(port)]).lower()
+        return "-".join([protocol, str(port)]).lower()
 
     def get_port(self, port, protocol):
         for item in self.ports:
@@ -97,22 +112,13 @@ def remove_port(self, port, protocol):
             return True
         return False
 
-    def refresh_k8s_svc(self):
-        if self.canary:
-            self._refresh_k8s_svc(self._svc_name(False))
-        else:
-            self._delete_k8s_svc(self._svc_name(True))
-        self._refresh_k8s_svc(self._svc_name(self.canary))
-
     def save(self, *args, **kwargs):
         service = super(Service, self).save(*args, **kwargs)
         self.refresh_k8s_svc()
         return service
 
     def delete(self, *args, **kwargs):
-        if self.canary:
-            self._delete_k8s_svc(self._svc_name(False))
-        self._delete_k8s_svc(self._svc_name(self.canary))
+        self._delete_k8s_svc(self.name)
         # Delete from DB
         return super(Service, self).delete(*args, **kwargs)
 
@@ -125,39 +131,27 @@ 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 _namespace(self):
-        return self.app.id
-
-    def _svc_name(self, canary):
-        if self.procfile_type == 'web':
-            svc_name = self.app.id
-        else:
-            svc_name = "{}-{}".format(self.app.id, self.procfile_type)
-        if canary:
-            svc_name = "%s-canary" % svc_name
-        return svc_name
-
-    def _refresh_k8s_svc(self, svc_name):
-        namespace = self._namespace()
-        self.log('creating service: {}'.format(svc_name), level=logging.DEBUG)
+    def refresh_k8s_svc(self):
+        self.log('creating service: {}'.format(self.name), level=logging.DEBUG)
         try:
             try:
-                data = self.scheduler().svc.get(namespace, svc_name).json()
-                self.scheduler().svc.patch(namespace, svc_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"],
-                    "procfile_type": self.procfile_type,
+                    "ptype": self.ptype,
                 })
             except KubeException:
-                self.scheduler().svc.create(namespace, svc_name, **{
+                self.scheduler.svc.create(self.namespace, self.name, **{
                     "ports": self.ports,
-                    "procfile_type": self.procfile_type,
+                    "ptype": self.ptype,
                 })
         except KubeException as e:
             raise ServiceUnavailable('Kubernetes service could not be created') from e
 
     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 68fbb4332..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:
-                    logger.log(
-                        msg="certificate {} does not exist".format(namespace), level=logging.INFO)
-                    self.scheduler().certificate.create(namespace, name, hosts)
+                    self.log(
+                        "certificate {} does not exist".format(namespace), level=logging.INFO)
+                    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 5cfe3ae5f..01a41b54c 100644
--- a/rootfs/api/models/volume.py
+++ b/rootfs/api/models/volume.py
@@ -1,9 +1,15 @@
 import logging
+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, AlreadyExists
+from api.exceptions import DryccException, ServiceUnavailable
 from scheduler import KubeException
 from .base import UuidAuditedModel
 
@@ -15,8 +21,8 @@ class Volume(UuidAuditedModel):
     TYPE_CHOICES = (
         ("csi", "container storage interface"),
         ("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)
@@ -24,11 +30,23 @@ class Volume(UuidAuditedModel):
     type = models.CharField(default=TYPE_CHOICES[0][0], choices=TYPE_CHOICES)
     parameters = models.JSONField(default=dict)
 
+    @property
+    def pv_name(self):
+        md5 = hashlib.md5()
+        md5.update(f"{self.app.id}-{self.type}-{self.name}".encode("utf-8"))
+        hexdigest = md5.hexdigest()
+        return "pvc-{}".format("-".join([
+            hexdigest[:8],
+            hexdigest[8:12],
+            hexdigest[12:16],
+            hexdigest[16:20],
+            hexdigest[20:]
+        ]))
+
     @transaction.atomic
     def save(self, *args, **kwargs):
         # Attach volume, updates k8s
-        if self.type == "csi" and self.created == self.updated:
-            self._create_pvc()
+        self.save_to_k8s()
         # check path
         self.check_path()
         # Save to DB
@@ -36,34 +54,11 @@ def save(self, *args, **kwargs):
 
     @transaction.atomic
     def delete(self, *args, **kwargs):
-        if self.path:
-            raise DryccException("the volume is not unmounted")
         # Deatch volume, updates k8s
-        if self.type == "csi":
-            self._delete_pvc()
+        self.delete_from_k8s()
         # Delete from DB
         return super(Volume, self).delete(*args, **kwargs)
 
-    @transaction.atomic
-    def expand(self, size):
-        if self.type == "csi":
-            if unit_to_bytes(size) < unit_to_bytes(self.size):
-                raise DryccException('Shrink volume is not supported.')
-            self.size = size
-            self.save()
-            try:
-                kwargs = {
-                    "size": self._format_size(self.size),
-                    "storage_class": settings.DRYCC_APP_STORAGE_CLASS,
-                }
-                self.scheduler().pvc.patch(self.app.id, self.name, **kwargs)
-            except KubeException as e:
-                msg = 'There was a problem expand the volume ' \
-                        '{} for {}'.format(self.name, self.app_id)
-                raise ServiceUnavailable(msg) from e
-        else:
-            raise DryccException(f'{self.type} volume is not support expand.')
-
     def log(self, message, level=logging.INFO):
         """Logs a message in the context of this service.
 
@@ -73,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",
@@ -92,16 +88,6 @@ def to_measurements(self, timestamp: float):
     def __str__(self):
         return self.name
 
-    @staticmethod
-    def _format_size(size):
-        """ Format volume limit value """
-        if size[-2:-1].isalpha() and size[-1].isalpha():
-            size = size[:-1]
-
-        if size[-1].isalpha():
-            size = size.upper() + "i"
-        return size
-
     def check_path(self, path=None):
         other_volumes = self.app.volume_set.exclude(name=self.name)
         type_paths = {}  # {'type1':[path1,path2], tyep2:[path3,path4]}
@@ -118,33 +104,95 @@ def check_path(self, path=None):
             self.log(msg, logging.ERROR)
             raise DryccException(msg)
 
-    def _create_pvc(self):
+    def save_to_k8s(self):
+        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_VOLUME_TEMPLATE:
+            self._delete_pv()
+        self._delete_pvc()
+
+    @staticmethod
+    def _format_size(size):
+        """ Format volume limit value """
+        if size[-2:-1].isalpha() and size[-1].isalpha():
+            size = size[:-1]
+
+        if size[-1].isalpha():
+            size = size.upper() + "i"
+        return 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,
+            "volume_handle": "%s" % uuid.uuid4(),
+        })
+        json_str = Template(settings.DRYCC_VOLUME_TEMPLATE).render(Context(kwds)).strip()
+        if not json_str:
+            return
+        kwargs = json.loads(json_str)
         try:
-            self.scheduler().pvc.get(self.app.id, self.name)
-            err = "Volume {} already exists in this namespace".format(self.name)
-            self.log(err, logging.INFO)
-            raise AlreadyExists(err)
+            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)
         except KubeException as e:
             logger.info(e)
             try:
-                kwargs = {
-                    "size": self._format_size(self.size),
-                    "storage_class": settings.DRYCC_APP_STORAGE_CLASS,
-                }
-                self.scheduler().pvc.create(self.app.id, self.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)
+                raise ServiceUnavailable(msg) from e
+
+    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)
+        except KubeException as e:
+            logger.exception(e)
+
+    def _save_pvc(self):
+        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,
+        })
+        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)
+            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)
+        except KubeException as e:
+            logger.info(e)
+            try:
+                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)
                 raise ServiceUnavailable(msg) from e
 
     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)
+            self.scheduler.pvc.get(self.app.id, self.name)
+            self.scheduler.pvc.delete(self.app.id, self.name)
         except KubeException as e:
-            raise ServiceUnavailable("Could not delete volume {} for application \
-                {}".format(self.name, self.app_id)) from e  # noqa
+            logger.exception(e)
 
     class Meta:
         get_latest_by = 'created'
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 7b507ade6..df8a1f79e 100644
--- a/rootfs/api/monitor.py
+++ b/rootfs/api/monitor.py
@@ -1,206 +1,62 @@
 import time
-import requests
-from typing import Iterator, Dict
-from contextlib import closing
-from urllib.parse import urljoin
-from django.db import connections
+import aiohttp
+from typing import Iterator, AsyncGenerator
 from django.conf import settings
+from django.template import Template, Context
 
 
-query_network_flow_sql_tpl = """
-SELECT
-  namespace,
-  pod_name,
-  last(rx_bytes, time) - first(rx_bytes, time) as rx_bytes,
-  last(tx_bytes, time) - first(tx_bytes, time) as tx_bytes
-FROM kubernetes_pod_network
-LEFT OUTER JOIN kubernetes_pod_network_tag
-ON kubernetes_pod_network.tag_id = kubernetes_pod_network_tag.tag_id
-WHERE
-  namespace in ({namespace_range})
-  AND time > to_timestamp({start})
-  AND time < to_timestamp({stop})
-GROUP by namespace, pod_name
-"""
+query_last_metrics_promql_tpl = Template("""
+last_over_time({__name__=~"{{metrics}}",namespace="{{namespace}}"}[{{duration}}])
+""")
 
 
-query_container_count_sql_tpl = """
-SELECT
-  count(1)
-FROM (
-  SELECT
-    namespace,
-    pod_name,
-    container_name
-  FROM kubernetes_pod_container
-  LEFT OUTER JOIN kubernetes_pod_container_tag
-  ON kubernetes_pod_container.tag_id = kubernetes_pod_container_tag.tag_id
-  WHERE
-    namespace='{namespace}'
-    AND container_name='{container_name}'
-    AND time > to_timestamp({start})
-    AND time < to_timestamp({stop})
-  GROUP BY namespace, pod_name, container_name, kubernetes_pod_container.tag_id
-) AS container
-GROUP BY namespace, container_name
-"""
+async def query_prom(url, params) -> list[tuple[dict[str, str], int]]:
+    async with aiohttp.ClientSession() as session:
+        async with session.get(url, params=params) as response:
+            if response.status != 200:
+                return []
+            response_json = await response.json()
+            if response_json['status'] != 'success':
+                return []
+            return response_json['data']['result']
 
 
-query_cpu_usage_sql_tpl = """
-SELECT
-  namespace,
-  pod_name,
-  container_name,
-  round(EXTRACT(EPOCH FROM time_bucket('{every}', time))) as timestamp,
-  max(cpu_usage_nanocores),
-  round(avg(cpu_usage_nanocores))
-FROM kubernetes_pod_container
-LEFT OUTER JOIN kubernetes_pod_container_tag
-ON kubernetes_pod_container.tag_id = kubernetes_pod_container_tag.tag_id
-WHERE
-  namespace='{namespace}'
-  AND container_name='{container_name}'
-  AND time > to_timestamp({start})
-  AND time < to_timestamp({stop})
-GROUP BY namespace, pod_name, container_name, timestamp
-"""
-
-
-query_memory_usage_sql_tpl = """
-SELECT
-  namespace,
-  pod_name,
-  container_name,
-  round(EXTRACT(EPOCH FROM time_bucket('{every}', time))) as timestamp,
-  max(memory_usage_bytes) as max,
-  round(avg(memory_usage_bytes)) as avg
-FROM kubernetes_pod_container
-LEFT OUTER JOIN kubernetes_pod_container_tag
-ON kubernetes_pod_container.tag_id = kubernetes_pod_container_tag.tag_id
-WHERE
-  namespace='{namespace}'
-  AND container_name='{container_name}'
-  AND time > to_timestamp({start})
-  AND time < to_timestamp({stop})
-GROUP BY namespace, pod_name, container_name, timestamp
-"""
-
-
-query_network_usage_sql_tpl = """
-SELECT
-  namespace,
-  pod_name,
-  round(EXTRACT(EPOCH FROM time_bucket('{every}', time))) as timestamp,
-  last(rx_bytes, time) - first(rx_bytes, time) as rx_bytes,
-  last(tx_bytes, time) - first(tx_bytes, time) as tx_bytes
-FROM kubernetes_pod_network
-LEFT OUTER JOIN kubernetes_pod_network_tag
-ON kubernetes_pod_network.tag_id = kubernetes_pod_network_tag.tag_id
-WHERE
-  namespace='{namespace}'
-  AND pod_name like '{pod_name_prefix}%'
-  AND time > to_timestamp({start})
-  AND time < to_timestamp({stop})
-GROUP by namespace, pod_name, timestamp
-"""
-
-
-query_loadbalancer_promql_tpl = """
-kube_service_status_load_balancer_ingress{namespace=~"%s"}
-"""
-
-query_last_metrics_promql_tpl = """
-last_over_time({__name__=~"%s",namespace="%s"}[%s])
-"""
-
-
-def last_metrics(namespace):
+async def last_metrics(namespace) -> AsyncGenerator[Iterator, str]:
     if not settings.DRYCC_METRICS_CONFIG:
-        return ()
-    promql = query_last_metrics_promql_tpl % (
-        '|'.join(settings.DRYCC_METRICS_CONFIG.keys()),
-        namespace,
-        '1m'
-    )
-    params = {"query": promql, "start": int(time.time() - 60)}
-    response = requests.get(
-        urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query"),
-        params=params
-    )
-    if response.status_code != 200:
-        return ()
-    return ('%s{%s} %s\n' % (
-        item['metric']['__name__'],
-        ','.join([
-            f'{key}="{value}"' for key, value in item['metric'].items()
-            if key in settings.DRYCC_METRICS_CONFIG[item['metric']['__name__']]
-          ]),
-        item['value'][1],
-    ) for item in response.json()['data']['result'])
-
-
-def query_loadbalancer(namespaces: Iterator[str],
-                       start: int, stop: int) -> Iterator[Dict[str, str]]:
-    promql = query_loadbalancer_promql_tpl % "|".join(namespaces)
-    params = {"query": promql, "start": start, "end": stop}
-    response = requests.get(
-        urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query"), params=params)
-    if response.status_code != 200:
         return
-    yield from (metric["metric"] for metric in response.json()["data"]["result"])
-
-
-def query_network_flow(namespaces: Iterator[str],
-                       start: int, stop: int) -> Iterator[tuple[str, str, int, int]]:
-    with closing(connections['monitor'].cursor()) as cursor:
-        namespace_range = ', '.join([f"'{namespace}'" for namespace in namespaces])
-        sql = query_network_flow_sql_tpl.format(
-            namespace_range=namespace_range, start=start, stop=stop)
-        cursor.execute(sql)
-        yield from cursor
-
-
-def query_container_count(namespace: str, container_type: str, start: int, stop: int) -> int:
-    with closing(connections['monitor'].cursor()) as cursor:
-        container_name = "%s-%s" % (namespace, container_type)
-        sql = query_container_count_sql_tpl.format(
-            namespace=namespace, container_name=container_name, start=start, stop=stop)
-        cursor.execute(sql)
-        row = cursor.fetchone()
-        return row[0] if row else 0
-
-
-def query_cpu_usage(namespace: str, container_type: str,
-                    start: int, stop: int, every: str
-                    ) -> Iterator[tuple[str, str, str, int, int, int]]:
-    with closing(connections['monitor'].cursor()) as cursor:
-        container_name = "%s-%s" % (namespace, container_type)
-        sql = query_cpu_usage_sql_tpl.format(
-            namespace=namespace, container_name=container_name,
-            start=start, stop=stop, every=every)
-        cursor.execute(sql)
-        yield from cursor
-
-
-def query_memory_usage(namespace: str, container_type: str,
-                       start: int, stop: int, every: str
-                       ) -> Iterator[tuple[str, str, str, int, int, int]]:
-    with closing(connections['monitor'].cursor()) as cursor:
-        container_name = "%s-%s" % (namespace, container_type)
-        sql = query_memory_usage_sql_tpl.format(
-            namespace=namespace, container_name=container_name,
-            start=start, stop=stop, every=every)
-        cursor.execute(sql)
-        yield from cursor
-
-
-def query_network_usage(namespace: str, container_type: str,
-                        start: int, stop: int, every: str
-                        ) -> Iterator[tuple[str, str, int, int, int]]:
-    with closing(connections['monitor'].cursor()) as cursor:
-        pod_name_prefix = "%s-%s" % (namespace, container_type)
-        sql = query_network_usage_sql_tpl.format(
-            namespace=namespace, pod_name_prefix=pod_name_prefix,
-            start=start, stop=stop, every=every)
-        cursor.execute(sql)
-        yield from cursor
+    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__'],
+            ','.join([
+                f'{key}="{value}"' for key, value in item['metric'].items()
+                if key in settings.DRYCC_METRICS_CONFIG[item['metric']['__name__']]
+            ]),
+            item['value'][1]
+        )
+
+
+async def query_volume_usage(namespaces: Iterator[str], start: int, stop: int
+                             ) -> list[tuple[dict[str, str], int]]:
+    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_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})
diff --git a/rootfs/api/permissions.py b/rootfs/api/permissions.py
index 4d652324d..4bd7f90d1 100644
--- a/rootfs/api/permissions.py
+++ b/rootfs/api/permissions.py
@@ -1,53 +1,22 @@
 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.blocklist import Blocklist, App
+from api.models import blocklist
+from api.models.workspace import Workspace, WorkspaceMember
 
 
 def get_app_status(app):
-    blocklist = Blocklist.get_blocklist(app)
-    if blocklist:
-        return False, blocklist.remark
+    block = blocklist.Blocklist.get_blocklist(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(request, obj):
-    if isinstance(obj, App) or hasattr(obj, 'app'):
-        app = obj if isinstance(obj, App) else obj.app
-        is_ok, message = get_app_status(app)
-        if is_ok:
-            if request.user.is_superuser:
-                return True, None
-            elif app.owner == request.user:
-                return True, None
-            elif request.user.is_staff or request.user.has_perm('use_app', app):
-                if request.method != 'DELETE':
-                    return True, None
-                else:
-                    return False, "User does not have permission to delete"
-        else:
-            return is_ok, message
-    return False, "App object does not exist or does not have permission."
-
-
-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):
-    """
-    Object-level permission to allow only owners of an object or administrators to access it.
-    Assumes the model instance has an `owner` attribute.
-    """
-    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 IsAppUser(permissions.BasePermission):
     """
-    Object-level permission to allow owners or collaborators to access
-    an app-related model.
+    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):
-        return has_app_permission(request, obj)[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
+    def has_object_permission(self, request, view, obj):
+        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/routers.py b/rootfs/api/routers.py
index 801d6c95c..3e5e98115 100644
--- a/rootfs/api/routers.py
+++ b/rootfs/api/routers.py
@@ -32,4 +32,8 @@ def allow_relation(self, obj1, obj2, **hints):
         return True
 
     def allow_migrate(self, db, app_label, model_name=None, **hints):
+        if 'replica' in settings.DATABASES and 'model' in hints:
+            model = hints['model']
+            tracker_key = ".".join([model.__module__, model.__name__])
+            setattr(self._tracker, tracker_key, 'default')
         return True
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 9ec6e80f8..146736d93 100644
--- a/rootfs/api/serializers/__init__.py
+++ b/rootfs/api/serializers/__init__.py
@@ -15,14 +15,17 @@
 
 
 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
 from scheduler.resources.pod import DEFAULT_CONTAINER_PORT
-from .schemas.rules import SCHEMA as RULES_SCHEMA
+from .schemas import rules
+from .schemas.config import SCHEMA as CONFIG_SCHEMA
 from .schemas.volumes import SCHEMA as VOLUMES_SCHEMA
 from .schemas.autoscale import SCHEMA as AUTOSCALE_SCHEMA
 from .schemas.healthcheck import SCHEMA as HEALTHCHECK_SCHEMA
-from .schemas.dryccfile import (SCHEMA as DRYCCFILE_SCHEMA, PROCTYPE_REGEX)
+from .schemas.dryccfile import (SCHEMA as DRYCCFILE_SCHEMA, PROCTYPE_REGEX, CONFIGKEY_REGEX)
 
 
 User = get_user_model()
@@ -38,37 +41,61 @@
     "the route kind only supports: %s" % ROUTE_PROTOCOL_MATCH.pattern)
 PROCTYPE_MATCH = re.compile(PROCTYPE_REGEX)
 PROCTYPE_MISMATCH_MSG = "Process types can only supports: %s" % PROCTYPE_MATCH.pattern
-MEMLIMIT_MATCH = re.compile(r'^(?P<mem>([1-9][0-9]*[mgMG]))$', re.IGNORECASE)
-MEMLIMIT_MISMATCH_MSG = (
-    "Memory limit format: <number><unit>, "
-    "where unit = M or G"
-)
-CPUSHARE_MATCH = re.compile(r'^(?P<cpu>([-+]?[1-9][0-9]*[m]?))$')
-CPUSHARE_MISMATCH_MSG = "CPU limit format: <value>, where value must be a numeric"
+
 TAGVAL_MATCH = re.compile(r'^(?:[a-zA-Z\d][-\.\w]{0,61})?[a-zA-Z\d]$')
-CONFIGKEY_MATCH = re.compile(r'^[a-z_]+[a-z0-9_]*$', re.IGNORECASE)
-CONFIGKEY_MISMATCH_MSG = (
-    "Config keys must start with a letter or underscore and "
-    "only contain [A-z0-9_]"
-)
+CONFIGKEY_MATCH = re.compile(CONFIGKEY_REGEX, re.IGNORECASE)
+CONFIGKEY_MISMATCH_MSG = ("Config key can only supports: %s" % CONFIGKEY_MATCH.pattern)
 CONFIG_LIMITS_MISMATCH_MSG = "The limit plan {} does not exist"
 
 TERMINATION_GRACE_PERIOD_MATCH = re.compile(r'^[0-9]*$')
 TERMINATION_GRACE_PERIOD_MISMATCH_MSG = (
     "Termination Grace Period format: %s" % TERMINATION_GRACE_PERIOD_MATCH.pattern)
-VOLUME_TYPE_MATCH = re.compile(r'^(csi|nfs)$')
+VOLUME_TYPE_MATCH = re.compile(r'^(%s)$' % "|".join([v[0] for v in Volume.TYPE_CHOICES]))
 VOLUME_TYPE_MISMATCH_MSG = "Volume type pattern: %s" % VOLUME_TYPE_MATCH.pattern
 VOLUME_SIZE_MATCH = re.compile(r'^(?P<volume>([1-9][0-9]*[gG]))$', re.IGNORECASE)
 VOLUME_SIZE_MISMATCH_MSG = (
     "Volume size limit format: <number><unit> or <number><unit>/<number><unit>, "
     "where unit = G, range: %sG~%sG"
 ) % (settings.KUBERNETES_LIMITS_MAX_VOLUME, settings.KUBERNETES_LIMITS_MIN_VOLUME)
-VOLUME_PATH_MATCH = re.compile(r'^\/(\w+\/?)+$', re.IGNORECASE)
+VOLUME_PATH_MATCH = re.compile(r'^\/([\w]+[\w-]*\/?)+$', re.IGNORECASE)
 METRIC_EVERY_MATCH = re.compile(r'^[1-9][0-9]*m$')
 HEALTHCHECK_MATCH = re.compile(r'^(livenessProbe|readinessProbe|startupProbe)$')
 HEALTHCHECK_MISMATCH_MSG = "Healthcheck pattern: %s" % HEALTHCHECK_MATCH.pattern
 
 
+def validate_port(value):
+    if not str(value).isnumeric():
+        raise serializers.ValidationError('port can only be a numeric value')
+    elif int(value) not in range(1, 65536):
+        raise serializers.ValidationError('port needs to be between 1 and 65535')
+    return value
+
+
+def validate_ptype(value):
+    if not re.match(PROCTYPE_MATCH, value):
+        raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
+    if len(value) < PTYPE_MIN_LENGTH or len(value) > PTYPE_MAX_LENGTH:
+        raise serializers.ValidationError(
+            "The length of ptype must be between {} and {}".format(
+                PTYPE_MIN_LENGTH, PTYPE_MAX_LENGTH))
+    validate_reserved_names(value)
+    return value
+
+
+def validate_name(value):
+    NAME_REGEX = r'^(?P<name>{})$'.format(settings.NAME_REGEX)
+    NAME_MATCH = re.compile(NAME_REGEX)
+    NAME_MISMATCH_MSG = "name can only supports: %s" % NAME_MATCH.pattern
+    if not re.match(NAME_REGEX, value):
+        raise serializers.ValidationError(NAME_MISMATCH_MSG)
+    if len(value) < PTYPE_MIN_LENGTH or len(value) > PTYPE_MAX_LENGTH:
+        raise serializers.ValidationError(
+            "The length of name must be between {} and {}".format(
+                PTYPE_MIN_LENGTH, PTYPE_MAX_LENGTH))
+    validate_reserved_names(value)
+    return value
+
+
 class JSONFieldSerializer(serializers.JSONField):
     def __init__(self, *args, **kwargs):
         self.convert_to_str = kwargs.pop('convert_to_str', True)
@@ -84,19 +111,22 @@ def to_internal_value(self, data):
 
     def to_representation(self, obj):
         """Serialize the field's JSON data, for read operations."""
-        for k, v in obj.items():
-            if v is None:  # NoneType is used to unset a value
-                continue
-
-            try:
-                if isinstance(v, (dict, list)):
-                    self.to_representation(v)
-                elif self.convert_to_str:
-                    obj[k] = str(v)
-            except ValueError:
-                obj[k] = v
-                # Do nothing, the validator will catch this later
+        if isinstance(obj, dict):
+            for k, v in obj.items():
+                if v is None:  # NoneType is used to unset a value
+                    continue
 
+                try:
+                    if isinstance(v, (dict, list)):
+                        self.to_representation(v)
+                    elif self.convert_to_str:
+                        obj[k] = str(v)
+                except ValueError:
+                    obj[k] = v
+                    # Do nothing, the validator will catch this later
+        elif isinstance(obj, list):
+            for i in range(len(obj)):
+                obj[i] = self.to_representation(obj[i])
         return obj
 
 
@@ -118,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}}
@@ -171,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
@@ -199,16 +231,20 @@ def validate_procfile(data):
         for key, value in data.items():
             if value is None or value == "":
                 raise serializers.ValidationError("Command can't be empty for process type")
-
-            if not re.match(PROCTYPE_MATCH, key):
-                raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
-
+            validate_ptype(key)
         return data
 
     @staticmethod
     def validate_dryccfile(data):
+        ptypes = set()
         if data:
-            return validate_json(data, DRYCCFILE_SCHEMA, serializers.ValidationError)
+            validate_json(data, DRYCCFILE_SCHEMA, serializers.ValidationError)
+            for pipeline in data['pipeline'].values():
+                if pipeline['ptype'] in ptypes:
+                    raise serializers.ValidationError("Pipeline's ptype must be unique")
+                else:
+                    validate_ptype(pipeline['ptype'])
+                    ptypes.add(pipeline['ptype'])
         return data
 
 
@@ -243,12 +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)
-    typed_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)
@@ -262,37 +295,30 @@ class Meta:
 
     @staticmethod
     def validate_values(data):
-        for key, value in data.items():
-            if not re.match(CONFIGKEY_MATCH, key):
+        validate_json(data, CONFIG_SCHEMA, serializers.ValidationError)
+        for item in data:
+            if (
+                not re.match(CONFIGKEY_MATCH, item['name']) or
+                (item.get('group') and not re.match(CONFIGKEY_MATCH, item['group']))
+            ):
                 raise serializers.ValidationError(CONFIGKEY_MISMATCH_MSG)
-            if value is None:  # use NoneType to unset an item
+            if item['value'] is None:  # use NoneType to unset an item
                 continue
             # Validate PORT
-            if key == 'PORT':
-                if not str(value).isnumeric():
+            if item['name'] == 'PORT':
+                if not str(item['value']).isnumeric():
                     raise serializers.ValidationError('PORT can only be a numeric value')
-                elif int(value) not in range(1, 65536):
+                elif int(item['value']) not in range(1, 65536):
                     # check if hte port is between 1 and 65535. One extra added for range()
                     # http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_serviceport
                     raise serializers.ValidationError('PORT needs to be between 1 and 65535')
         return data
 
-    @classmethod
-    def validate_typed_values(cls, data):
-        for procfile_type, values in data.items():
-            if not re.match(PROCTYPE_MATCH, procfile_type):
-                raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
-            if values is None:  # use NoneType to unset an item
-                continue
-            cls.validate_values(values)
-        return data
-
     @staticmethod
     def validate_limits(data):
         req_plan_ids = []
-        for procfile_type, plan_id in data.items():
-            if not re.match(PROCTYPE_MATCH, procfile_type):
-                raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
+        for ptype, plan_id in data.items():
+            validate_ptype(ptype)
             if plan_id is not None:
                 req_plan_ids.append(plan_id)
         plan_ids = [plan.id for plan in models.limit.LimitPlan.objects.filter(
@@ -304,9 +330,8 @@ def validate_limits(data):
 
     @staticmethod
     def validate_termination_grace_period(data):
-        for procfile_type, value in data.items():
-            if not re.match(PROCTYPE_MATCH, procfile_type):
-                raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
+        for ptype, value in data.items():
+            validate_ptype(ptype)
             if value is None:  # use NoneType to unset an item
                 continue
             timeout = re.match(TERMINATION_GRACE_PERIOD_MATCH, str(value))
@@ -318,55 +343,68 @@ def validate_termination_grace_period(data):
 
     @staticmethod
     def validate_tags(data):
-        for key, value in data.items():
-            if value is None:  # use NoneType to unset an item
-                continue
-
-            # split key into a prefix and name
-            if '/' in key:
-                prefix, name = key.split('/')
-            else:
-                prefix, name = None, key
+        for ptype, values in data.items():
+            validate_ptype(ptype)
+            for key, value in values.items():
+                if value is None:  # use NoneType to unset an item
+                    continue
 
-            # validate optional prefix
-            if prefix:
-                if len(prefix) > 253:
-                    raise serializers.ValidationError(
-                        "Tag key prefixes must 253 characters or less.")
+                # split key into a prefix and name
+                if '/' in key:
+                    prefix, name = key.split('/')
+                else:
+                    prefix, name = None, key
 
-                for part in prefix.split('/'):
-                    if not re.match(TAGVAL_MATCH, part):
+                # validate optional prefix
+                if prefix:
+                    if len(prefix) > 253:
                         raise serializers.ValidationError(
-                            "Tag key prefixes must be DNS subdomains.")
+                            "Tag key prefixes must 253 characters or less.")
 
-            # validate required name
-            if not re.match(TAGVAL_MATCH, name):
-                raise serializers.ValidationError(
-                    "Tag keys must be alphanumeric or \"-_.\", and 1-63 characters.")
+                    for part in prefix.split('/'):
+                        if not re.match(TAGVAL_MATCH, part):
+                            raise serializers.ValidationError(
+                                "Tag key prefixes must be DNS subdomains.")
 
-            # validate value if it isn't empty
-            if value and not re.match(TAGVAL_MATCH, str(value)):
-                raise serializers.ValidationError(
-                    "Tag values must be alphanumeric or \"-_.\", and 1-63 characters.")
+                # validate required name
+                if not re.match(TAGVAL_MATCH, name):
+                    raise serializers.ValidationError(
+                        "Tag keys must be alphanumeric or \"-_.\", and 1-63 characters.")
+
+                # validate value if it isn't empty
+                if value and not re.match(TAGVAL_MATCH, str(value)):
+                    raise serializers.ValidationError(
+                        "Tag values must be alphanumeric or \"-_.\", and 1-63 characters.")
+        return data
 
+    @staticmethod
+    def validate_values_refs(data):
+        for ptype, values in data.items():
+            validate_ptype(ptype)
+            for value in values:
+                if value is None:
+                    continue
+                if not re.match(CONFIGKEY_MATCH, value):
+                    raise serializers.ValidationError(CONFIGKEY_MISMATCH_MSG)
         return data
 
     @staticmethod
     def validate_registry(data):
-        for key, value in data.items():
-            if value is None:  # use NoneType to unset an item
+        for ptype, values in data.items():
+            validate_ptype(ptype)
+            if not values:
                 continue
-
-            if not re.match(CONFIGKEY_MATCH, key):
-                raise serializers.ValidationError(CONFIGKEY_MISMATCH_MSG)
-
+            for key, value in values.items():
+                if value is None:
+                    continue
+                if not re.match(CONFIGKEY_MATCH, key):
+                    raise serializers.ValidationError(CONFIGKEY_MISMATCH_MSG)
         return data
 
     @staticmethod
     def validate_healthcheck(data):
-        for procfile_type, healthcheck in data.items():
-            if not re.match(PROCTYPE_MATCH, procfile_type):
-                raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
+        for ptype, healthcheck in data.items():
+            validate_ptype(ptype)
             if healthcheck is None:
                 continue
             for key, value in healthcheck.items():
@@ -382,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`."""
@@ -405,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', 'procfile_type']
+        fields = ['created', 'updated', 'app', 'domain', 'ptype']
         read_only_fields = ['uuid']
 
     @staticmethod
@@ -460,41 +496,29 @@ def ToACE(x): return idna.alabel(x).decode("utf-8", "strict")
 
         return aceValue
 
-    @staticmethod
-    def validate_procfile_type(value):
-        if not re.match(PROCTYPE_MATCH, value):
-            raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
-
-        return value
+    validate_ptype = staticmethod(validate_ptype)
 
 
 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)
-    procfile_type = serializers.CharField(required=True)
+    ptype = serializers.CharField(required=True)
 
     class Meta:
         """Metadata options for a :class:`ServiceSerializer`."""
         model = models.service.Service
-        fields = ['owner', 'created', 'updated', 'app', 'procfile_type']
+        fields = ['created', 'updated', 'app', 'ptype']
         read_only_fields = ['uuid']
 
-    @staticmethod
-    def validate_port(value):
-        if not str(value).isnumeric():
-            raise serializers.ValidationError('port can only be a numeric value')
-        elif int(value) not in range(1, 65536):
-            raise serializers.ValidationError('port needs to be between 1 and 65535')
-        return value
-
     @staticmethod
     def validate_protocol(value):
-        if not re.match(SERVICE_PROTOCOL_MATCH, value):
+        if value is None or value == "":
+            value = "TCP"
+        elif not re.match(SERVICE_PROTOCOL_MATCH, value):
             raise serializers.ValidationError(SERVICE_PROTOCOL_MISMATCH_MSG)
         return value
 
@@ -502,18 +526,14 @@ def validate_protocol(value):
     def validate_target_port(cls, value):
         return cls.validate_port(value)
 
-    @staticmethod
-    def validate_procfile_type(value):
-        if not re.match(PROCTYPE_MATCH, value):
-            raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
-
-        return value
+    validate_port = staticmethod(validate_port)
+    validate_ptype = staticmethod(validate_ptype)
 
 
 class CertificateSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.certificate.Certificate` model."""
 
-    owner = serializers.ReadOnlyField(source='owner.username')
+    app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
     domains = serializers.ReadOnlyField()
     san = serializers.ListField(
         child=serializers.CharField(allow_blank=True, allow_null=True, required=False),
@@ -537,6 +557,29 @@ class PodSerializer(serializers.BaseSerializer):
     type = serializers.CharField()
     release = serializers.CharField(required=False)
     started = serializers.DateTimeField(required=False)
+    ready = serializers.CharField()
+    restarts = serializers.IntegerField()
+
+    def to_representation(self, obj):
+        return obj
+
+
+class EventSerializer(serializers.BaseSerializer):
+    reason = serializers.CharField()
+    message = serializers.CharField()
+    created = serializers.DateTimeField(required=False)
+
+    def to_representation(self, obj):
+        return obj
+
+
+class PtypeSerializer(serializers.BaseSerializer):
+    name = serializers.CharField(required=False)
+    release = serializers.CharField()
+    ready = serializers.CharField()
+    up_to_date = serializers.CharField()
+    available_replicas = serializers.CharField(required=False)
+    started = serializers.DateTimeField(required=False)
 
     def to_representation(self, obj):
         return obj
@@ -546,8 +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')
-    canaries = serializers.JSONField(required=False)
     autoscale = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
     label = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
 
@@ -558,7 +599,8 @@ class Meta:
 
     @staticmethod
     def validate_autoscale(data):
-        for _, autoscale in data.items():
+        for ptype, autoscale in data.items():
+            validate_ptype(ptype)
             if autoscale is None:
                 continue
             validate_json(autoscale, AUTOSCALE_SCHEMA, serializers.ValidationError)
@@ -569,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:
@@ -582,11 +623,7 @@ 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')
-    name = serializers.CharField()
-    size = serializers.CharField(required=False)
     path = JSONFieldSerializer(required=False, binary=True)
-    type = serializers.CharField(required=False)
     parameters = serializers.JSONField(required=False)
 
     class Meta:
@@ -607,13 +644,14 @@ def validate_size(self, data):
             raise serializers.ValidationError(VOLUME_SIZE_MISMATCH_MSG)
         return data.upper()
 
+    validate_name = staticmethod(validate_name)
+
     @staticmethod
     def validate_path(data):
         logger.debug(f"mount validate_path data: {data}")
         new_data = {}
         for key, value in data.items():
-            if not re.match(PROCTYPE_MATCH, key):
-                raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
+            validate_ptype(key)
             if value is None:  # use NoneType to unset an item
                 new_data[key] = value
                 continue
@@ -643,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)
@@ -665,6 +702,8 @@ def update(self, instance, validated_data):
         instance.save()
         return instance
 
+    validate_name = staticmethod(validate_name)
+
 
 class MetricSerializer(serializers.Serializer):
     start = serializers.IntegerField(
@@ -673,6 +712,8 @@ class MetricSerializer(serializers.Serializer):
     stop = serializers.IntegerField(
         min_value=946656000, max_value=4102416000,
         required=False, default=lambda: int(time.time()))
+    step = serializers.IntegerField(
+        min_value=1, max_value=30, required=False, default=14)
     every = serializers.CharField(max_length=50, required=False, default='5m')
 
     def validate(self, attrs):
@@ -695,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)
@@ -705,13 +745,7 @@ def to_representation(self, instance):
         representation['addresses'] = instance.addresses
         return representation
 
-    @staticmethod
-    def validate_port(value):
-        if not str(value).isnumeric():
-            raise serializers.ValidationError('port can only be a numeric value')
-        elif int(value) not in range(1, 65536):
-            raise serializers.ValidationError('port needs to be between 1 and 65535')
-        return value
+    validate_name = staticmethod(validate_name)
 
     @staticmethod
     def validate_protocol(value):
@@ -719,31 +753,23 @@ def validate_protocol(value):
             raise serializers.ValidationError(GATEWAY_PROTOCOL_MISMATCH_MSG)
         return value
 
-    @staticmethod
-    def validate_procfile_type(value):
-        if not re.match(PROCTYPE_MATCH, value):
-            raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
-
-        return value
+    validate_port = staticmethod(validate_port)
+    validate_ptype = staticmethod(validate_ptype)
 
 
-class RouteSerializer(serializers.Serializer):
+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=False)
+    kind = serializers.CharField(max_length=15, required=True)
     name = serializers.CharField(max_length=63, required=True)
-    port = serializers.IntegerField()
-    procfile_type = serializers.CharField(max_length=63, required=True)
     rules = serializers.JSONField(required=False)
     parent_refs = serializers.JSONField(required=False)
 
-    @staticmethod
-    def validate_port(value):
-        if not str(value).isnumeric():
-            raise serializers.ValidationError('port can only be a numeric value')
-        elif int(value) not in range(1, 65536):
-            raise serializers.ValidationError('port needs to be between 1 and 65535')
-        return value
+    class Meta:
+        """Metadata options for a :class:`RouteSerializer`."""
+        model = models.gateway.Route
+        fields = '__all__'
+
+    validate_name = staticmethod(validate_name)
 
     @staticmethod
     def validate_kind(value):
@@ -751,13 +777,50 @@ def validate_kind(value):
             raise serializers.ValidationError(ROUTE_PROTOCOL_MISMATCH_MSG)
         return value
 
-    @staticmethod
-    def validate_procfile_type(value):
-        if not re.match(PROCTYPE_MATCH, value):
-            raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
+    def validate_rules(self, value):
+        kind = getattr(self, "initial_data", {}).get("kind", None)
+        if kind:
+            schema = getattr(rules, f"{kind.replace("Route", "")}_RULES_SCHEMA", "SCHEMA")
+        else:
+            schema = rules.SCHEMA
+        return validate_json(value, schema, serializers.ValidationError)
 
-        return value
 
-    @staticmethod
-    def validate_rules(value):
-        return validate_json(value, RULES_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/config.py b/rootfs/api/serializers/schemas/config.py
new file mode 100644
index 000000000..4b15877d5
--- /dev/null
+++ b/rootfs/api/serializers/schemas/config.py
@@ -0,0 +1,30 @@
+SCHEMA = {
+    "description": "Config values are a list of config item.",
+    "$schema": "http://json-schema.org/schema#",
+    "items": {
+        "oneOf": [
+            {
+                "type": "object",
+                "properties": {
+                    "name": {"type": "string"},
+                    "ptype": {"type": "string"},
+                    "value": {"oneOf": [{"type": "string"}, {"type": "null"}]},
+                },
+                "required": ["name", "ptype", "value"],
+                "additionalProperties": False,
+            },
+            {
+                "type": "object",
+                "properties": {
+                    "name": {"type": "string"},
+                    "group": {"type": "string"},
+                    "value": {"oneOf": [{"type": "string"}, {"type": "null"}]},
+                },
+                "required": ["name", "group", "value"],
+                "additionalProperties": False,
+            },
+        ]
+    },
+    "minItems": 0,
+    "type": "array"
+}
diff --git a/rootfs/api/serializers/schemas/dryccfile.py b/rootfs/api/serializers/schemas/dryccfile.py
index 0c956aa72..bb9729d2b 100644
--- a/rootfs/api/serializers/schemas/dryccfile.py
+++ b/rootfs/api/serializers/schemas/dryccfile.py
@@ -1,56 +1,85 @@
-PROCTYPE_REGEX = r'^(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)(?<!-canary)$'
 
+FILENAME_REGEX = r'^(?P<name>[a-z0-9]+(\.[a-z0-9]+)*)$'
+PROCTYPE_REGEX = r'^(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)$'
+CONFIGKEY_REGEX = r'^[A-z0-9_\-\.]+$'
+CONFIGENV_SCHEMA = {
+    "type": "object",
+    "patternProperties": {
+        CONFIGKEY_REGEX: {"type": "string"},
+    },
+}
 SCHEMA = {
     "$schema": "http://json-schema.org/schema#",
     "type": "object",
     "properties": {
-        "build": {
+        "config": {
             "type": "object",
-            "properties": {
-                "docker": {
-                    "type": "object",
-                    "patternProperties": {
-                        PROCTYPE_REGEX: {"type": "string"},
-                    },
-                    "additionalProperties": False,
-                },
-                "config": {"type": "object"}
-            },
-        },
-        "run": {
-            "type": "object",
-            "properties": {
-                "image": {"type": "string"},
-                "command": {
-                    "type": "array",
-                    "items": {"type": "string"},
-                },
-                "args": {
-                    "type": "array",
-                    "items": {"type": "string"},
-                }
+            "patternProperties": {
+                FILENAME_REGEX: CONFIGENV_SCHEMA,
             },
         },
-        "deploy": {
+        "pipeline": {
             "type": "object",
             "patternProperties": {
-                PROCTYPE_REGEX: {
+                FILENAME_REGEX: {
+                    "type": "object",
                     "properties": {
-                        "image": {"type": "string"},
-                        "command": {
-                            "type": "array",
-                            "items": {"type": "string"},
+                        "kind": {"type": "string", "pattern": "^pipeline$"},
+                        "ptype": {"type": "string", "pattern": PROCTYPE_REGEX},
+                        "build": {
+                            "type": "object",
+                            "properties": {
+                                "docker": {"type": "string"},
+                                "arg": CONFIGENV_SCHEMA,
+                            },
+                            "additionalProperties": False,
+                        },
+                        "env": CONFIGENV_SCHEMA,
+                        "run": {
+                            "type": "object",
+                            "properties": {
+                                "command": {
+                                    "type": "array",
+                                    "items": {"type": "string"},
+                                },
+                                "args": {
+                                    "type": "array",
+                                    "items": {"type": "string"},
+                                },
+                                "image": {"type": "string"},
+                                "timeout": {"type": "integer"},
+                            },
+                            "additionalProperties": False,
                         },
-                        "args": {
+                        "config": {
                             "type": "array",
-                            "items": {"type": "string"},
+                            "items": {
+                                "type": "string",
+                                "pattern": FILENAME_REGEX,
+                            },
                         },
-                    }
+                        "deploy": {
+                            "type": "object",
+                            "properties": {
+                                "command": {
+                                    "type": "array",
+                                    "items": {"type": "string"},
+                                },
+                                "args": {
+                                    "type": "array",
+                                    "items": {"type": "string"},
+                                },
+                                "image": {"type": "string"},
+                            },
+                            "additionalProperties": False,
+                        }
+                    },
+                    "additionalProperties": False,
+                    "required": ["kind", "ptype", "deploy"],
                 },
             },
-            "minProperties": 1,
-            "additionalProperties": False,
         },
     },
-    "required": ["deploy"],
+    "required": ["pipeline"],
+    "additionalProperties": False,
 }
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/serializers/schemas/rules.py b/rootfs/api/serializers/schemas/rules.py
index 97f9f12e0..e12b6ae3c 100644
--- a/rootfs/api/serializers/schemas/rules.py
+++ b/rootfs/api/serializers/schemas/rules.py
@@ -1,211 +1,2417 @@
-HTTP_HEADER_FILTER_SCHEMA = {
-    "set": {
-        "type": "array",
-        "items": {
-            "type": "object",
-            "properties": {
-                "name": {"type": "string"},
-                "value": {"type": "string"}
-            }
-        }
-    },
-    "add": {
-        "type": "array",
-        "items": {
-            "type": "object",
-            "properties": {
-                "name": {"type": "string"},
-                "value": {"type": "string"}
+# flake8: noqa
+
+
+TCP_RULES_SCHEMA = {
+    "description": "Rules are a list of TCP matchers and actions.",
+    "items": {
+        "description": "TCPRouteRule is the configuration for a given rule.",
+        "properties": {
+            "backendRefs": {
+                "description": "BackendRefs defines the backend(s) where matching requests should be\nsent. If unspecified or invalid (refers to a non-existent resource or a\nService with no endpoints), the underlying implementation MUST actively\nreject connection attempts to this backend. Connection rejections must\nrespect weight; if an invalid backend is requested to have 80% of\nconnections, then 80% of connections must be rejected instead.\n\n\nSupport: Core for Kubernetes Service\n\n\nSupport: Extended for Kubernetes ServiceImport\n\n\nSupport: Implementation-specific for any other resource\n\n\nSupport for weight: Extended",
+                "items": {
+                    "description": "BackendRef defines how a Route should forward a request to a Kubernetes\nresource.\n\n\nNote that when a namespace different than the local namespace is specified, a\nReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\n<gateway:experimental:description>\n\n\nWhen the BackendRef points to a Kubernetes Service, implementations SHOULD\nhonor the appProtocol field if it is set for the target Service Port.\n\n\nImplementations supporting appProtocol SHOULD recognize the Kubernetes\nStandard Application Protocols defined in KEP-3726.\n\n\nIf a Service appProtocol isn't specified, an implementation MAY infer the\nbackend protocol through its own means. Implementations MAY infer the\nprotocol from the Route type referring to the backend Service.\n\n\nIf a Route is not able to send traffic to the backend using the specified\nprotocol then the backend is considered invalid. Implementations MUST set the\n\"ResolvedRefs\" condition to \"False\" with the \"UnsupportedProtocol\" reason.\n\n\n</gateway:experimental:description>\n\n\nNote that when the BackendTLSPolicy object is enabled by the implementation,\nthere are some extra rules about validity to consider here. See the fields\nwhere this struct is used for more information about the exact behavior.",
+                    "properties": {
+                        "group": {
+                            "default": "",
+                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                            "maxLength": 253,
+                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                            "type": "string"
+                        },
+                        "kind": {
+                            "default": "Service",
+                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                            "type": "string"
+                        },
+                        "name": {
+                            "description": "Name is the name of the referent.",
+                            "maxLength": 253,
+                            "minLength": 1,
+                            "type": "string"
+                        },
+                        "namespace": {
+                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                            "type": "string"
+                        },
+                        "port": {
+                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                            "format": "int32",
+                            "maximum": 65535,
+                            "minimum": 1,
+                            "type": "integer"
+                        },
+                        "weight": {
+                            "default": 1,
+                            "description": "Weight specifies the proportion of requests forwarded to the referenced\nbackend. This is computed as weight/(sum of all weights in this\nBackendRefs list). For non-zero values, there may be some epsilon from\nthe exact proportion defined here depending on the precision an\nimplementation supports. Weight is not a percentage and the sum of\nweights does not need to equal 100.\n\n\nIf only one backend is specified and it has a weight greater than 0, 100%\nof the traffic is forwarded to that backend. If weight is set to 0, no\ntraffic should be forwarded for this entry. If unspecified, weight\ndefaults to 1.\n\n\nSupport for this field varies based on the context where used.",
+                            "format": "int32",
+                            "maximum": 1000000,
+                            "minimum": 0,
+                            "type": "integer"
+                        }
+                    },
+                    "required": [
+                        "name"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "Must have port for Service reference",
+                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                        }
+                    ]
+                },
+                "maxItems": 16,
+                "minItems": 1,
+                "type": "array"
             }
-        }
+        },
+        "type": "object"
     },
-    "remove": {"type": "array"}
+    "maxItems": 16,
+    "minItems": 1,
+    "type": "array"
 }
 
-FILTER_SCHEMA = {
-    # Type identifies the type of filter to apply.
-    # As with other API fields, types are classified into three conformance levels:
-    "type": {
-        "type": "string",
-        "enum": ["ExtensionRef", "RequestHeaderModifier", "RequestMirror", "RequestRedirect", "ResponseHeaderModifier", "URLRewrite"],  # noqa
-    },
-    # ExtensionRef is an optional, implementation-specific extension to the “filter” behavior. # noqa
-    # For example, resource “myroutefilter” in group “networking.example.net”).
-    # ExtensionRef MUST NOT be used for core and extended filters.
-    "extensionRef": {
-        "type": "object",
+UDP_RULES_SCHEMA = {
+    "description": "Rules are a list of UDP matchers and actions.",
+    "items": {
+        "description": "UDPRouteRule is the configuration for a given rule.",
         "properties": {
-            "group": {"type": "string"},
-            "kind": {"type": "string"},
-            "name": {"type": "string"}
+            "backendRefs": {
+                "description": "BackendRefs defines the backend(s) where matching requests should be\nsent. If unspecified or invalid (refers to a non-existent resource or a\nService with no endpoints), the underlying implementation MUST actively\nreject connection attempts to this backend. Packet drops must\nrespect weight; if an invalid backend is requested to have 80% of\nthe packets, then 80% of packets must be dropped instead.\n\n\nSupport: Core for Kubernetes Service\n\n\nSupport: Extended for Kubernetes ServiceImport\n\n\nSupport: Implementation-specific for any other resource\n\n\nSupport for weight: Extended",
+                "items": {
+                    "description": "BackendRef defines how a Route should forward a request to a Kubernetes\nresource.\n\n\nNote that when a namespace different than the local namespace is specified, a\nReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\n<gateway:experimental:description>\n\n\nWhen the BackendRef points to a Kubernetes Service, implementations SHOULD\nhonor the appProtocol field if it is set for the target Service Port.\n\n\nImplementations supporting appProtocol SHOULD recognize the Kubernetes\nStandard Application Protocols defined in KEP-3726.\n\n\nIf a Service appProtocol isn't specified, an implementation MAY infer the\nbackend protocol through its own means. Implementations MAY infer the\nprotocol from the Route type referring to the backend Service.\n\n\nIf a Route is not able to send traffic to the backend using the specified\nprotocol then the backend is considered invalid. Implementations MUST set the\n\"ResolvedRefs\" condition to \"False\" with the \"UnsupportedProtocol\" reason.\n\n\n</gateway:experimental:description>\n\n\nNote that when the BackendTLSPolicy object is enabled by the implementation,\nthere are some extra rules about validity to consider here. See the fields\nwhere this struct is used for more information about the exact behavior.",
+                    "properties": {
+                        "group": {
+                            "default": "",
+                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                            "maxLength": 253,
+                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                            "type": "string"
+                        },
+                        "kind": {
+                            "default": "Service",
+                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                            "type": "string"
+                        },
+                        "name": {
+                            "description": "Name is the name of the referent.",
+                            "maxLength": 253,
+                            "minLength": 1,
+                            "type": "string"
+                        },
+                        "namespace": {
+                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                            "type": "string"
+                        },
+                        "port": {
+                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                            "format": "int32",
+                            "maximum": 65535,
+                            "minimum": 1,
+                            "type": "integer"
+                        },
+                        "weight": {
+                            "default": 1,
+                            "description": "Weight specifies the proportion of requests forwarded to the referenced\nbackend. This is computed as weight/(sum of all weights in this\nBackendRefs list). For non-zero values, there may be some epsilon from\nthe exact proportion defined here depending on the precision an\nimplementation supports. Weight is not a percentage and the sum of\nweights does not need to equal 100.\n\n\nIf only one backend is specified and it has a weight greater than 0, 100%\nof the traffic is forwarded to that backend. If weight is set to 0, no\ntraffic should be forwarded for this entry. If unspecified, weight\ndefaults to 1.\n\n\nSupport for this field varies based on the context where used.",
+                            "format": "int32",
+                            "maximum": 1000000,
+                            "minimum": 0,
+                            "type": "integer"
+                        }
+                    },
+                    "required": [
+                        "name"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "Must have port for Service reference",
+                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                        }
+                    ]
+                },
+                "maxItems": 16,
+                "minItems": 1,
+                "type": "array"
+            }
         },
-        "required": ["group", "kind", "name"],
-        "additionalProperties": False
-    },
-    # RequestHeaderModifier defines a schema for a filter that modifies request headers.
-    "requestHeaderModifier": {
-        "type": "object",
-        "properties": HTTP_HEADER_FILTER_SCHEMA,
-        "additionalProperties": False
+        "type": "object"
     },
-    # ResponseHeaderModifier defines a schema for a filter that modifies response headers.
-    "responseHeaderModifier": {
-        "type": "object",
-        "properties": HTTP_HEADER_FILTER_SCHEMA,
-        "additionalProperties": False
-    },
-    # RequestMirror defines a schema for a filter that mirrors requests.
-    # Requests are sent to the specified destination, but responses from that destination are ignored. # noqa
-    "requestMirror": {
-        "type": "object",
+    "maxItems": 16,
+    "minItems": 1,
+    "type": "array"
+}
+
+TLS_RULES_SCHEMA = {
+    "description": "Rules are a list of TLS matchers and actions.",
+    "items": {
+        "description": "TLSRouteRule is the configuration for a given rule.",
         "properties": {
-            "backendRef": {
-                "properties": {
-                    "group": {"type": "string"},
-                    "kind": {"type": "string"},
-                    "name": {"type": "string"},
-                    "namespace": {"type": "string"},
-                    "port": {"type": "integer"},
+            "backendRefs": {
+                "description": "BackendRefs defines the backend(s) where matching requests should be\nsent. If unspecified or invalid (refers to a non-existent resource or\na Service with no endpoints), the rule performs no forwarding; if no\nfilters are specified that would result in a response being sent, the\nunderlying implementation must actively reject request attempts to this\nbackend, by rejecting the connection or returning a 500 status code.\nRequest rejections must respect weight; if an invalid backend is\nrequested to have 80% of requests, then 80% of requests must be rejected\ninstead.\n\n\nSupport: Core for Kubernetes Service\n\n\nSupport: Extended for Kubernetes ServiceImport\n\n\nSupport: Implementation-specific for any other resource\n\n\nSupport for weight: Extended",
+                "items": {
+                    "description": "BackendRef defines how a Route should forward a request to a Kubernetes\nresource.\n\n\nNote that when a namespace different than the local namespace is specified, a\nReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\n<gateway:experimental:description>\n\n\nWhen the BackendRef points to a Kubernetes Service, implementations SHOULD\nhonor the appProtocol field if it is set for the target Service Port.\n\n\nImplementations supporting appProtocol SHOULD recognize the Kubernetes\nStandard Application Protocols defined in KEP-3726.\n\n\nIf a Service appProtocol isn't specified, an implementation MAY infer the\nbackend protocol through its own means. Implementations MAY infer the\nprotocol from the Route type referring to the backend Service.\n\n\nIf a Route is not able to send traffic to the backend using the specified\nprotocol then the backend is considered invalid. Implementations MUST set the\n\"ResolvedRefs\" condition to \"False\" with the \"UnsupportedProtocol\" reason.\n\n\n</gateway:experimental:description>\n\n\nNote that when the BackendTLSPolicy object is enabled by the implementation,\nthere are some extra rules about validity to consider here. See the fields\nwhere this struct is used for more information about the exact behavior.",
+                    "properties": {
+                        "group": {
+                            "default": "",
+                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                            "maxLength": 253,
+                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                            "type": "string"
+                        },
+                        "kind": {
+                            "default": "Service",
+                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                            "type": "string"
+                        },
+                        "name": {
+                            "description": "Name is the name of the referent.",
+                            "maxLength": 253,
+                            "minLength": 1,
+                            "type": "string"
+                        },
+                        "namespace": {
+                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                            "type": "string"
+                        },
+                        "port": {
+                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                            "format": "int32",
+                            "maximum": 65535,
+                            "minimum": 1,
+                            "type": "integer"
+                        },
+                        "weight": {
+                            "default": 1,
+                            "description": "Weight specifies the proportion of requests forwarded to the referenced\nbackend. This is computed as weight/(sum of all weights in this\nBackendRefs list). For non-zero values, there may be some epsilon from\nthe exact proportion defined here depending on the precision an\nimplementation supports. Weight is not a percentage and the sum of\nweights does not need to equal 100.\n\n\nIf only one backend is specified and it has a weight greater than 0, 100%\nof the traffic is forwarded to that backend. If weight is set to 0, no\ntraffic should be forwarded for this entry. If unspecified, weight\ndefaults to 1.\n\n\nSupport for this field varies based on the context where used.",
+                            "format": "int32",
+                            "maximum": 1000000,
+                            "minimum": 0,
+                            "type": "integer"
+                        }
+                    },
+                    "required": [
+                        "name"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "Must have port for Service reference",
+                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                        }
+                    ]
                 },
-                "required": ["name"],
-                "additionalProperties": False
-            },
+                "maxItems": 16,
+                "minItems": 1,
+                "type": "array"
+            }
         },
-        "required": ["backendRef"],
-        "additionalProperties": False
-    },
-    # RequestRedirect defines a schema for a filter that responds to the request with an HTTP redirection. # noqa
-    "requestRedirect": {
-        "type": "object",
-        "properties": {
-            "scheme": {"type": "string"},
-            "hostname": {"type": "string"},
-            "path": {"type": "string"},
-            "port": {"type": "integer"},
-            "statusCode": {"type": "integer"}
-        }
+        "type": "object"
     },
-    # URLRewrite defines a schema for a filter that modifies a request during forwarding
-    "urlRewrite": {
-        "hostname": {"type": "string"},
-        "path": {"type": "string"},
-    }
+    "maxItems": 16,
+    "minItems": 1,
+    "type": "array"
 }
 
-RULES_SCHEMA = {
-    "type": "array",
+HTTP_RULES_SCHEMA = {
+    "description": "Rules are a list of HTTP matchers, filters and actions.",
     "items": {
         "properties": {
-            # Matches define conditions used for matching the rule against incoming HTTP requests. # noqa
-            # Each match is independent, i.e. this rule will be matched if any one of the matches is satisfied. # noqa
-            # More info: https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1beta1.HTTPRouteMatch # noqa
-            "matches": {
+            "backendRefs": {
+                "items": {
+                    "properties": {
+                        "filters": {
+                            "items": {
+                                "properties": {
+                                    "extensionRef": {
+                                        "properties": {
+                                            "group": {
+                                                "description": "",
+                                                "maxLength": 253,
+                                                "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                                "type": "string"
+                                            },
+                                            "kind": {
+                                                "description": "Kind is kind of the referent. For example \"HTTPRoute\" or \"Service\".",
+                                                "maxLength": 63,
+                                                "minLength": 1,
+                                                "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                                "type": "string"
+                                            },
+                                            "name": {
+                                                "description": "Name is the name of the referent.",
+                                                "maxLength": 253,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "group",
+                                            "kind",
+                                            "name"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "requestHeaderModifier": {
+                                        "description": "RequestHeaderModifier defines a schema for a filter that modifies request\nheaders.\n\n\nSupport: Core",
+                                        "properties": {
+                                            "add": {
+                                                "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            },
+                                            "remove": {
+                                                "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                                "items": {
+                                                    "type": "string"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-type": "set"
+                                            },
+                                            "set": {
+                                                "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            }
+                                        },
+                                        "type": "object"
+                                    },
+                                    "requestMirror": {
+                                        "description": "RequestMirror defines a schema for a filter that mirrors requests.\nRequests are sent to the specified destination, but responses from\nthat destination are ignored.\n\n\nThis filter can be used multiple times within the same rule. Note that\nnot all implementations will be able to support mirroring to multiple\nbackends.\n\n\nSupport: Extended",
+                                        "properties": {
+                                            "backendRef": {
+                                                "description": "BackendRef references a resource where mirrored requests are sent.\n\n\nMirrored requests must be sent only to a single destination endpoint\nwithin this BackendRef, irrespective of how many endpoints are present\nwithin this BackendRef.\n\n\nIf the referent cannot be found, this BackendRef is invalid and must be\ndropped from the Gateway. The controller must ensure the \"ResolvedRefs\"\ncondition on the Route status is set to `status: False` and not configure\nthis backend in the underlying implementation.\n\n\nIf there is a cross-namespace reference to an *existing* object\nthat is not allowed by a ReferenceGrant, the controller must ensure the\n\"ResolvedRefs\"  condition on the Route is set to `status: False`,\nwith the \"RefNotPermitted\" reason and not configure this backend in the\nunderlying implementation.\n\n\nIn either error case, the Message of the `ResolvedRefs` Condition\nshould be used to provide more detail about the problem.\n\n\nSupport: Extended for Kubernetes Service\n\n\nSupport: Implementation-specific for any other resource",
+                                                "properties": {
+                                                    "group": {
+                                                        "default": "",
+                                                        "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                                        "maxLength": 253,
+                                                        "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                                        "type": "string"
+                                                    },
+                                                    "kind": {
+                                                        "default": "Service",
+                                                        "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                                                        "maxLength": 63,
+                                                        "minLength": 1,
+                                                        "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                                        "type": "string"
+                                                    },
+                                                    "name": {
+                                                        "description": "Name is the name of the referent.",
+                                                        "maxLength": 253,
+                                                        "minLength": 1,
+                                                        "type": "string"
+                                                    },
+                                                    "namespace": {
+                                                        "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                                                        "maxLength": 63,
+                                                        "minLength": 1,
+                                                        "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                                                        "type": "string"
+                                                    },
+                                                    "port": {
+                                                        "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                                                        "format": "int32",
+                                                        "maximum": 65535,
+                                                        "minimum": 1,
+                                                        "type": "integer"
+                                                    }
+                                                },
+                                                "required": [
+                                                    "name"
+                                                ],
+                                                "type": "object",
+                                                "x-kubernetes-validations": [
+                                                    {
+                                                        "message": "Must have port for Service reference",
+                                                        "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                                                    }
+                                                ]
+                                            }
+                                        },
+                                        "required": [
+                                            "backendRef"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "requestRedirect": {
+                                        "description": "RequestRedirect defines a schema for a filter that responds to the\nrequest with an HTTP redirection.\n\n\nSupport: Core",
+                                        "properties": {
+                                            "hostname": {
+                                                "description": "Hostname is the hostname to be used in the value of the `Location`\nheader in the response.\nWhen empty, the hostname in the `Host` header of the request is used.\n\n\nSupport: Core",
+                                                "maxLength": 253,
+                                                "minLength": 1,
+                                                "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                                "type": "string"
+                                            },
+                                            "path": {
+                                                "description": "Path defines parameters used to modify the path of the incoming request.\nThe modified path is then used to construct the `Location` header. When\nempty, the request path is used as-is.\n\n\nSupport: Extended",
+                                                "properties": {
+                                                    "replaceFullPath": {
+                                                        "description": "ReplaceFullPath specifies the value with which to replace the full path\nof a request during a rewrite or redirect.",
+                                                        "maxLength": 1024,
+                                                        "type": "string"
+                                                    },
+                                                    "replacePrefixMatch": {
+                                                        "description": "ReplacePrefixMatch specifies the value with which to replace the prefix\nmatch of a request during a rewrite or redirect. For example, a request\nto \"/foo/bar\" with a prefix match of \"/foo\" and a ReplacePrefixMatch\nof \"/xyz\" would be modified to \"/xyz/bar\".\n\n\nNote that this matches the behavior of the PathPrefix match type. This\nmatches full path elements. A path element refers to the list of labels\nin the path split by the `/` separator. When specified, a trailing `/` is\nignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all\nmatch the prefix `/abc`, but the path `/abcd` would not.\n\n\nReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch.\nUsing any other HTTPRouteMatch type on the same HTTPRouteRule will result in\nthe implementation setting the Accepted Condition for the Route to `status: False`.\n\n\nRequest Path | Prefix Match | Replace Prefix | Modified Path\n-------------|--------------|----------------|----------\n/foo/bar     | /foo         | /xyz           | /xyz/bar\n/foo/bar     | /foo         | /xyz/          | /xyz/bar\n/foo/bar     | /foo/        | /xyz           | /xyz/bar\n/foo/bar     | /foo/        | /xyz/          | /xyz/bar\n/foo         | /foo         | /xyz           | /xyz\n/foo/        | /foo         | /xyz           | /xyz/\n/foo/bar     | /foo         | <empty string> | /bar\n/foo/        | /foo         | <empty string> | /\n/foo         | /foo         | <empty string> | /\n/foo/        | /foo         | /              | /\n/foo         | /foo         | /              | /",
+                                                        "maxLength": 1024,
+                                                        "type": "string"
+                                                    },
+                                                    "type": {
+                                                        "description": "Type defines the type of path modifier. Additional types may be\nadded in a future release of the API.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.",
+                                                        "enum": [
+                                                            "ReplaceFullPath",
+                                                            "ReplacePrefixMatch"
+                                                        ],
+                                                        "type": "string"
+                                                    }
+                                                },
+                                                "required": [
+                                                    "type"
+                                                ],
+                                                "type": "object",
+                                                "x-kubernetes-validations": [
+                                                    {
+                                                        "message": "replaceFullPath must be specified when type is set to 'ReplaceFullPath'",
+                                                        "rule": "self.type == 'ReplaceFullPath' ? has(self.replaceFullPath) : true"
+                                                    },
+                                                    {
+                                                        "message": "type must be 'ReplaceFullPath' when replaceFullPath is set",
+                                                        "rule": "has(self.replaceFullPath) ? self.type == 'ReplaceFullPath' : true"
+                                                    },
+                                                    {
+                                                        "message": "replacePrefixMatch must be specified when type is set to 'ReplacePrefixMatch'",
+                                                        "rule": "self.type == 'ReplacePrefixMatch' ? has(self.replacePrefixMatch) : true"
+                                                    },
+                                                    {
+                                                        "message": "type must be 'ReplacePrefixMatch' when replacePrefixMatch is set",
+                                                        "rule": "has(self.replacePrefixMatch) ? self.type == 'ReplacePrefixMatch' : true"
+                                                    }
+                                                ]
+                                            },
+                                            "port": {
+                                                "description": "Port is the port to be used in the value of the `Location`\nheader in the response.\n\n\nIf no port is specified, the redirect port MUST be derived using the\nfollowing rules:\n\n\n* If redirect scheme is not-empty, the redirect port MUST be the well-known\n  port associated with the redirect scheme. Specifically \"http\" to port 80\n  and \"https\" to port 443. If the redirect scheme does not have a\n  well-known port, the listener port of the Gateway SHOULD be used.\n* If redirect scheme is empty, the redirect port MUST be the Gateway\n  Listener port.\n\n\nImplementations SHOULD NOT add the port number in the 'Location'\nheader in the following cases:\n\n\n* A Location header that will use HTTP (whether that is determined via\n  the Listener protocol or the Scheme field) _and_ use port 80.\n* A Location header that will use HTTPS (whether that is determined via\n  the Listener protocol or the Scheme field) _and_ use port 443.\n\n\nSupport: Extended",
+                                                "format": "int32",
+                                                "maximum": 65535,
+                                                "minimum": 1,
+                                                "type": "integer"
+                                            },
+                                            "scheme": {
+                                                "description": "Scheme is the scheme to be used in the value of the `Location` header in\nthe response. When empty, the scheme of the request is used.\n\n\nScheme redirects can affect the port of the redirect, for more information,\nrefer to the documentation for the port field of this filter.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.\n\n\nSupport: Extended",
+                                                "enum": [
+                                                    "http",
+                                                    "https"
+                                                ],
+                                                "type": "string"
+                                            },
+                                            "statusCode": {
+                                                "default": 302,
+                                                "description": "StatusCode is the HTTP status code to be used in response.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.\n\n\nSupport: Core",
+                                                "enum": [
+                                                    301,
+                                                    302
+                                                ],
+                                                "type": "integer"
+                                            }
+                                        },
+                                        "type": "object"
+                                    },
+                                    "responseHeaderModifier": {
+                                        "description": "ResponseHeaderModifier defines a schema for a filter that modifies response\nheaders.\n\n\nSupport: Extended",
+                                        "properties": {
+                                            "add": {
+                                                "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            },
+                                            "remove": {
+                                                "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                                "items": {
+                                                    "type": "string"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-type": "set"
+                                            },
+                                            "set": {
+                                                "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            }
+                                        },
+                                        "type": "object"
+                                    },
+                                    "type": {
+                                        "description": "Type identifies the type of filter to apply. As with other API fields,\ntypes are classified into three conformance levels:\n\n\n- Core: Filter types and their corresponding configuration defined by\n  \"Support: Core\" in this package, e.g. \"RequestHeaderModifier\". All\n  implementations must support core filters.\n\n\n- Extended: Filter types and their corresponding configuration defined by\n  \"Support: Extended\" in this package, e.g. \"RequestMirror\". Implementers\n  are encouraged to support extended filters.\n\n\n- Implementation-specific: Filters that are defined and supported by\n  specific vendors.\n  In the future, filters showing convergence in behavior across multiple\n  implementations will be considered for inclusion in extended or core\n  conformance levels. Filter-specific configuration for such filters\n  is specified using the ExtensionRef field. `Type` should be set to\n  \"ExtensionRef\" for custom filters.\n\n\nImplementers are encouraged to define custom implementation types to\nextend the core API with implementation-specific behavior.\n\n\nIf a reference to a custom filter type cannot be resolved, the filter\nMUST NOT be skipped. Instead, requests that would have been processed by\nthat filter MUST receive a HTTP error response.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.",
+                                        "enum": [
+                                            "RequestHeaderModifier",
+                                            "ResponseHeaderModifier",
+                                            "RequestMirror",
+                                            "RequestRedirect",
+                                            "URLRewrite",
+                                            "ExtensionRef"
+                                        ],
+                                        "type": "string"
+                                    },
+                                    "urlRewrite": {
+                                        "description": "URLRewrite defines a schema for a filter that modifies a request during forwarding.\n\n\nSupport: Extended",
+                                        "properties": {
+                                            "hostname": {
+                                                "description": "Hostname is the value to be used to replace the Host header value during\nforwarding.\n\n\nSupport: Extended",
+                                                "maxLength": 253,
+                                                "minLength": 1,
+                                                "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                                "type": "string"
+                                            },
+                                            "path": {
+                                                "description": "Path defines a path rewrite.\n\n\nSupport: Extended",
+                                                "properties": {
+                                                    "replaceFullPath": {
+                                                        "description": "ReplaceFullPath specifies the value with which to replace the full path\nof a request during a rewrite or redirect.",
+                                                        "maxLength": 1024,
+                                                        "type": "string"
+                                                    },
+                                                    "replacePrefixMatch": {
+                                                        "description": "ReplacePrefixMatch specifies the value with which to replace the prefix\nmatch of a request during a rewrite or redirect. For example, a request\nto \"/foo/bar\" with a prefix match of \"/foo\" and a ReplacePrefixMatch\nof \"/xyz\" would be modified to \"/xyz/bar\".\n\n\nNote that this matches the behavior of the PathPrefix match type. This\nmatches full path elements. A path element refers to the list of labels\nin the path split by the `/` separator. When specified, a trailing `/` is\nignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all\nmatch the prefix `/abc`, but the path `/abcd` would not.\n\n\nReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch.\nUsing any other HTTPRouteMatch type on the same HTTPRouteRule will result in\nthe implementation setting the Accepted Condition for the Route to `status: False`.\n\n\nRequest Path | Prefix Match | Replace Prefix | Modified Path\n-------------|--------------|----------------|----------\n/foo/bar     | /foo         | /xyz           | /xyz/bar\n/foo/bar     | /foo         | /xyz/          | /xyz/bar\n/foo/bar     | /foo/        | /xyz           | /xyz/bar\n/foo/bar     | /foo/        | /xyz/          | /xyz/bar\n/foo         | /foo         | /xyz           | /xyz\n/foo/        | /foo         | /xyz           | /xyz/\n/foo/bar     | /foo         | <empty string> | /bar\n/foo/        | /foo         | <empty string> | /\n/foo         | /foo         | <empty string> | /\n/foo/        | /foo         | /              | /\n/foo         | /foo         | /              | /",
+                                                        "maxLength": 1024,
+                                                        "type": "string"
+                                                    },
+                                                    "type": {
+                                                        "description": "Type defines the type of path modifier. Additional types may be\nadded in a future release of the API.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.",
+                                                        "enum": [
+                                                            "ReplaceFullPath",
+                                                            "ReplacePrefixMatch"
+                                                        ],
+                                                        "type": "string"
+                                                    }
+                                                },
+                                                "required": [
+                                                    "type"
+                                                ],
+                                                "type": "object",
+                                                "x-kubernetes-validations": [
+                                                    {
+                                                        "message": "replaceFullPath must be specified when type is set to 'ReplaceFullPath'",
+                                                        "rule": "self.type == 'ReplaceFullPath' ? has(self.replaceFullPath) : true"
+                                                    },
+                                                    {
+                                                        "message": "type must be 'ReplaceFullPath' when replaceFullPath is set",
+                                                        "rule": "has(self.replaceFullPath) ? self.type == 'ReplaceFullPath' : true"
+                                                    },
+                                                    {
+                                                        "message": "replacePrefixMatch must be specified when type is set to 'ReplacePrefixMatch'",
+                                                        "rule": "self.type == 'ReplacePrefixMatch' ? has(self.replacePrefixMatch) : true"
+                                                    },
+                                                    {
+                                                        "message": "type must be 'ReplacePrefixMatch' when replacePrefixMatch is set",
+                                                        "rule": "has(self.replacePrefixMatch) ? self.type == 'ReplacePrefixMatch' : true"
+                                                    }
+                                                ]
+                                            }
+                                        },
+                                        "type": "object"
+                                    }
+                                },
+                                "required": [
+                                    "type"
+                                ],
+                                "type": "object",
+                                "x-kubernetes-validations": [
+                                    {
+                                        "message": "filter.requestHeaderModifier must be nil if the filter.type is not RequestHeaderModifier",
+                                        "rule": "!(has(self.requestHeaderModifier) && self.type != 'RequestHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.requestHeaderModifier must be specified for RequestHeaderModifier filter.type",
+                                        "rule": "!(!has(self.requestHeaderModifier) && self.type == 'RequestHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.responseHeaderModifier must be nil if the filter.type is not ResponseHeaderModifier",
+                                        "rule": "!(has(self.responseHeaderModifier) && self.type != 'ResponseHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.responseHeaderModifier must be specified for ResponseHeaderModifier filter.type",
+                                        "rule": "!(!has(self.responseHeaderModifier) && self.type == 'ResponseHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.requestMirror must be nil if the filter.type is not RequestMirror",
+                                        "rule": "!(has(self.requestMirror) && self.type != 'RequestMirror')"
+                                    },
+                                    {
+                                        "message": "filter.requestMirror must be specified for RequestMirror filter.type",
+                                        "rule": "!(!has(self.requestMirror) && self.type == 'RequestMirror')"
+                                    },
+                                    {
+                                        "message": "filter.requestRedirect must be nil if the filter.type is not RequestRedirect",
+                                        "rule": "!(has(self.requestRedirect) && self.type != 'RequestRedirect')"
+                                    },
+                                    {
+                                        "message": "filter.requestRedirect must be specified for RequestRedirect filter.type",
+                                        "rule": "!(!has(self.requestRedirect) && self.type == 'RequestRedirect')"
+                                    },
+                                    {
+                                        "message": "filter.urlRewrite must be nil if the filter.type is not URLRewrite",
+                                        "rule": "!(has(self.urlRewrite) && self.type != 'URLRewrite')"
+                                    },
+                                    {
+                                        "message": "filter.urlRewrite must be specified for URLRewrite filter.type",
+                                        "rule": "!(!has(self.urlRewrite) && self.type == 'URLRewrite')"
+                                    },
+                                    {
+                                        "message": "filter.extensionRef must be nil if the filter.type is not ExtensionRef",
+                                        "rule": "!(has(self.extensionRef) && self.type != 'ExtensionRef')"
+                                    },
+                                    {
+                                        "message": "filter.extensionRef must be specified for ExtensionRef filter.type",
+                                        "rule": "!(!has(self.extensionRef) && self.type == 'ExtensionRef')"
+                                    }
+                                ]
+                            },
+                            "maxItems": 16,
+                            "type": "array",
+                            "x-kubernetes-validations": [
+                                {
+                                    "message": "May specify either httpRouteFilterRequestRedirect or httpRouteFilterRequestRewrite, but not both",
+                                    "rule": "!(self.exists(f, f.type == 'RequestRedirect') && self.exists(f, f.type == 'URLRewrite'))"
+                                },
+                                {
+                                    "message": "May specify either httpRouteFilterRequestRedirect or httpRouteFilterRequestRewrite, but not both",
+                                    "rule": "!(self.exists(f, f.type == 'RequestRedirect') && self.exists(f, f.type == 'URLRewrite'))"
+                                },
+                                {
+                                    "message": "RequestHeaderModifier filter cannot be repeated",
+                                    "rule": "self.filter(f, f.type == 'RequestHeaderModifier').size() <= 1"
+                                },
+                                {
+                                    "message": "ResponseHeaderModifier filter cannot be repeated",
+                                    "rule": "self.filter(f, f.type == 'ResponseHeaderModifier').size() <= 1"
+                                },
+                                {
+                                    "message": "RequestRedirect filter cannot be repeated",
+                                    "rule": "self.filter(f, f.type == 'RequestRedirect').size() <= 1"
+                                },
+                                {
+                                    "message": "URLRewrite filter cannot be repeated",
+                                    "rule": "self.filter(f, f.type == 'URLRewrite').size() <= 1"
+                                }
+                            ]
+                        },
+                        "group": {
+                            "default": "",
+                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                            "maxLength": 253,
+                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                            "type": "string"
+                        },
+                        "kind": {
+                            "default": "Service",
+                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                            "type": "string"
+                        },
+                        "name": {
+                            "description": "Name is the name of the referent.",
+                            "maxLength": 253,
+                            "minLength": 1,
+                            "type": "string"
+                        },
+                        "namespace": {
+                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                            "type": "string"
+                        },
+                        "port": {
+                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                            "format": "int32",
+                            "maximum": 65535,
+                            "minimum": 1,
+                            "type": "integer"
+                        },
+                        "weight": {
+                            "default": 1,
+                            "description": "Weight specifies the proportion of requests forwarded to the referenced\nbackend. This is computed as weight/(sum of all weights in this\nBackendRefs list). For non-zero values, there may be some epsilon from\nthe exact proportion defined here depending on the precision an\nimplementation supports. Weight is not a percentage and the sum of\nweights does not need to equal 100.\n\n\nIf only one backend is specified and it has a weight greater than 0, 100%\nof the traffic is forwarded to that backend. If weight is set to 0, no\ntraffic should be forwarded for this entry. If unspecified, weight\ndefaults to 1.\n\n\nSupport for this field varies based on the context where used.",
+                            "format": "int32",
+                            "maximum": 1000000,
+                            "minimum": 0,
+                            "type": "integer"
+                        }
+                    },
+                    "required": [
+                        "name"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "Must have port for Service reference",
+                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                        }
+                    ]
+                },
+                "maxItems": 16,
+                "type": "array"
+            },
+            "filters": {
+                "description": "Filters define the filters that are applied to requests that match\nthis rule.\n\n\nWherever possible, implementations SHOULD implement filters in the order\nthey are specified.\n\n\nImplementations MAY choose to implement this ordering strictly, rejecting\nany combination or order of filters that can not be supported. If implementations\nchoose a strict interpretation of filter ordering, they MUST clearly document\nthat behavior.\n\n\nTo reject an invalid combination or order of filters, implementations SHOULD\nconsider the Route Rules with this configuration invalid. If all Route Rules\nin a Route are invalid, the entire Route would be considered invalid. If only\na portion of Route Rules are invalid, implementations MUST set the\n\"PartiallyInvalid\" condition for the Route.\n\n\nConformance-levels at this level are defined based on the type of filter:\n\n\n- ALL core filters MUST be supported by all implementations.\n- Implementers are encouraged to support extended filters.\n- Implementation-specific custom filters have no API guarantees across\n  implementations.\n\n\nSpecifying the same filter multiple times is not supported unless explicitly\nindicated in the filter.\n\n\nAll filters are expected to be compatible with each other except for the\nURLRewrite and RequestRedirect filters, which may not be combined. If an\nimplementation can not support other combinations of filters, they must clearly\ndocument that limitation. In cases where incompatible or unsupported\nfilters are specified and cause the `Accepted` condition to be set to status\n`False`, implementations may use the `IncompatibleFilters` reason to specify\nthis configuration error.\n\n\nSupport: Core",
+                "items": {
+                    "description": "HTTPRouteFilter defines processing steps that must be completed during the\nrequest or response lifecycle. HTTPRouteFilters are meant as an extension\npoint to express processing that may be done in Gateway implementations. Some\nexamples include request or response modification, implementing\nauthentication strategies, rate-limiting, and traffic shaping. API\nguarantee/conformance is defined based on the type of the filter.",
+                    "properties": {
+                        "extensionRef": {
+                            "description": "ExtensionRef is an optional, implementation-specific extension to the\n\"filter\" behavior.  For example, resource \"myroutefilter\" in group\n\"networking.example.net\"). ExtensionRef MUST NOT be used for core and\nextended filters.\n\n\nThis filter can be used multiple times within the same rule.\n\n\nSupport: Implementation-specific",
+                            "properties": {
+                                "group": {
+                                    "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                    "maxLength": 253,
+                                    "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                    "type": "string"
+                                },
+                                "kind": {
+                                    "description": "Kind is kind of the referent. For example \"HTTPRoute\" or \"Service\".",
+                                    "maxLength": 63,
+                                    "minLength": 1,
+                                    "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                    "type": "string"
+                                },
+                                "name": {
+                                    "description": "Name is the name of the referent.",
+                                    "maxLength": 253,
+                                    "minLength": 1,
+                                    "type": "string"
+                                }
+                            },
+                            "required": [
+                                "group",
+                                "kind",
+                                "name"
+                            ],
+                            "type": "object"
+                        },
+                        "requestHeaderModifier": {
+                            "description": "RequestHeaderModifier defines a schema for a filter that modifies request\nheaders.\n\n\nSupport: Core",
+                            "properties": {
+                                "add": {
+                                    "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                },
+                                "remove": {
+                                    "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-type": "set"
+                                },
+                                "set": {
+                                    "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                }
+                            },
+                            "type": "object"
+                        },
+                        "requestMirror": {
+                            "description": "RequestMirror defines a schema for a filter that mirrors requests.\nRequests are sent to the specified destination, but responses from\nthat destination are ignored.\n\n\nThis filter can be used multiple times within the same rule. Note that\nnot all implementations will be able to support mirroring to multiple\nbackends.\n\n\nSupport: Extended",
+                            "properties": {
+                                "backendRef": {
+                                    "description": "BackendRef references a resource where mirrored requests are sent.\n\n\nMirrored requests must be sent only to a single destination endpoint\nwithin this BackendRef, irrespective of how many endpoints are present\nwithin this BackendRef.\n\n\nIf the referent cannot be found, this BackendRef is invalid and must be\ndropped from the Gateway. The controller must ensure the \"ResolvedRefs\"\ncondition on the Route status is set to `status: False` and not configure\nthis backend in the underlying implementation.\n\n\nIf there is a cross-namespace reference to an *existing* object\nthat is not allowed by a ReferenceGrant, the controller must ensure the\n\"ResolvedRefs\"  condition on the Route is set to `status: False`,\nwith the \"RefNotPermitted\" reason and not configure this backend in the\nunderlying implementation.\n\n\nIn either error case, the Message of the `ResolvedRefs` Condition\nshould be used to provide more detail about the problem.\n\n\nSupport: Extended for Kubernetes Service\n\n\nSupport: Implementation-specific for any other resource",
+                                    "properties": {
+                                        "group": {
+                                            "default": "",
+                                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                            "maxLength": 253,
+                                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                            "type": "string"
+                                        },
+                                        "kind": {
+                                            "default": "Service",
+                                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                                            "maxLength": 63,
+                                            "minLength": 1,
+                                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                            "type": "string"
+                                        },
+                                        "name": {
+                                            "description": "Name is the name of the referent.",
+                                            "maxLength": 253,
+                                            "minLength": 1,
+                                            "type": "string"
+                                        },
+                                        "namespace": {
+                                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                                            "maxLength": 63,
+                                            "minLength": 1,
+                                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                                            "type": "string"
+                                        },
+                                        "port": {
+                                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                                            "format": "int32",
+                                            "maximum": 65535,
+                                            "minimum": 1,
+                                            "type": "integer"
+                                        }
+                                    },
+                                    "required": [
+                                        "name"
+                                    ],
+                                    "type": "object",
+                                    "x-kubernetes-validations": [
+                                        {
+                                            "message": "Must have port for Service reference",
+                                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                                        }
+                                    ]
+                                }
+                            },
+                            "required": [
+                                "backendRef"
+                            ],
+                            "type": "object"
+                        },
+                        "requestRedirect": {
+                            "description": "RequestRedirect defines a schema for a filter that responds to the\nrequest with an HTTP redirection.\n\n\nSupport: Core",
+                            "properties": {
+                                "hostname": {
+                                    "description": "Hostname is the hostname to be used in the value of the `Location`\nheader in the response.\nWhen empty, the hostname in the `Host` header of the request is used.\n\n\nSupport: Core",
+                                    "maxLength": 253,
+                                    "minLength": 1,
+                                    "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                    "type": "string"
+                                },
+                                "path": {
+                                    "description": "Path defines parameters used to modify the path of the incoming request.\nThe modified path is then used to construct the `Location` header. When\nempty, the request path is used as-is.\n\n\nSupport: Extended",
+                                    "properties": {
+                                        "replaceFullPath": {
+                                            "description": "ReplaceFullPath specifies the value with which to replace the full path\nof a request during a rewrite or redirect.",
+                                            "maxLength": 1024,
+                                            "type": "string"
+                                        },
+                                        "replacePrefixMatch": {
+                                            "description": "ReplacePrefixMatch specifies the value with which to replace the prefix\nmatch of a request during a rewrite or redirect. For example, a request\nto \"/foo/bar\" with a prefix match of \"/foo\" and a ReplacePrefixMatch\nof \"/xyz\" would be modified to \"/xyz/bar\".\n\n\nNote that this matches the behavior of the PathPrefix match type. This\nmatches full path elements. A path element refers to the list of labels\nin the path split by the `/` separator. When specified, a trailing `/` is\nignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all\nmatch the prefix `/abc`, but the path `/abcd` would not.\n\n\nReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch.\nUsing any other HTTPRouteMatch type on the same HTTPRouteRule will result in\nthe implementation setting the Accepted Condition for the Route to `status: False`.\n\n\nRequest Path | Prefix Match | Replace Prefix | Modified Path\n-------------|--------------|----------------|----------\n/foo/bar     | /foo         | /xyz           | /xyz/bar\n/foo/bar     | /foo         | /xyz/          | /xyz/bar\n/foo/bar     | /foo/        | /xyz           | /xyz/bar\n/foo/bar     | /foo/        | /xyz/          | /xyz/bar\n/foo         | /foo         | /xyz           | /xyz\n/foo/        | /foo         | /xyz           | /xyz/\n/foo/bar     | /foo         | <empty string> | /bar\n/foo/        | /foo         | <empty string> | /\n/foo         | /foo         | <empty string> | /\n/foo/        | /foo         | /              | /\n/foo         | /foo         | /              | /",
+                                            "maxLength": 1024,
+                                            "type": "string"
+                                        },
+                                        "type": {
+                                            "description": "Type defines the type of path modifier. Additional types may be\nadded in a future release of the API.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.",
+                                            "enum": [
+                                                "ReplaceFullPath",
+                                                "ReplacePrefixMatch"
+                                            ],
+                                            "type": "string"
+                                        }
+                                    },
+                                    "required": [
+                                        "type"
+                                    ],
+                                    "type": "object",
+                                    "x-kubernetes-validations": [
+                                        {
+                                            "message": "replaceFullPath must be specified when type is set to 'ReplaceFullPath'",
+                                            "rule": "self.type == 'ReplaceFullPath' ? has(self.replaceFullPath) : true"
+                                        },
+                                        {
+                                            "message": "type must be 'ReplaceFullPath' when replaceFullPath is set",
+                                            "rule": "has(self.replaceFullPath) ? self.type == 'ReplaceFullPath' : true"
+                                        },
+                                        {
+                                            "message": "replacePrefixMatch must be specified when type is set to 'ReplacePrefixMatch'",
+                                            "rule": "self.type == 'ReplacePrefixMatch' ? has(self.replacePrefixMatch) : true"
+                                        },
+                                        {
+                                            "message": "type must be 'ReplacePrefixMatch' when replacePrefixMatch is set",
+                                            "rule": "has(self.replacePrefixMatch) ? self.type == 'ReplacePrefixMatch' : true"
+                                        }
+                                    ]
+                                },
+                                "port": {
+                                    "description": "Port is the port to be used in the value of the `Location`\nheader in the response.\n\n\nIf no port is specified, the redirect port MUST be derived using the\nfollowing rules:\n\n\n* If redirect scheme is not-empty, the redirect port MUST be the well-known\n  port associated with the redirect scheme. Specifically \"http\" to port 80\n  and \"https\" to port 443. If the redirect scheme does not have a\n  well-known port, the listener port of the Gateway SHOULD be used.\n* If redirect scheme is empty, the redirect port MUST be the Gateway\n  Listener port.\n\n\nImplementations SHOULD NOT add the port number in the 'Location'\nheader in the following cases:\n\n\n* A Location header that will use HTTP (whether that is determined via\n  the Listener protocol or the Scheme field) _and_ use port 80.\n* A Location header that will use HTTPS (whether that is determined via\n  the Listener protocol or the Scheme field) _and_ use port 443.\n\n\nSupport: Extended",
+                                    "format": "int32",
+                                    "maximum": 65535,
+                                    "minimum": 1,
+                                    "type": "integer"
+                                },
+                                "scheme": {
+                                    "description": "Scheme is the scheme to be used in the value of the `Location` header in\nthe response. When empty, the scheme of the request is used.\n\n\nScheme redirects can affect the port of the redirect, for more information,\nrefer to the documentation for the port field of this filter.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.\n\n\nSupport: Extended",
+                                    "enum": [
+                                        "http",
+                                        "https"
+                                    ],
+                                    "type": "string"
+                                },
+                                "statusCode": {
+                                    "default": 302,
+                                    "description": "StatusCode is the HTTP status code to be used in response.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.\n\n\nSupport: Core",
+                                    "enum": [
+                                        301,
+                                        302
+                                    ],
+                                    "type": "integer"
+                                }
+                            },
+                            "type": "object"
+                        },
+                        "responseHeaderModifier": {
+                            "description": "ResponseHeaderModifier defines a schema for a filter that modifies response\nheaders.\n\n\nSupport: Extended",
+                            "properties": {
+                                "add": {
+                                    "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                },
+                                "remove": {
+                                    "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-type": "set"
+                                },
+                                "set": {
+                                    "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                }
+                            },
+                            "type": "object"
+                        },
+                        "type": {
+                            "description": "Type identifies the type of filter to apply. As with other API fields,\ntypes are classified into three conformance levels:\n\n\n- Core: Filter types and their corresponding configuration defined by\n  \"Support: Core\" in this package, e.g. \"RequestHeaderModifier\". All\n  implementations must support core filters.\n\n\n- Extended: Filter types and their corresponding configuration defined by\n  \"Support: Extended\" in this package, e.g. \"RequestMirror\". Implementers\n  are encouraged to support extended filters.\n\n\n- Implementation-specific: Filters that are defined and supported by\n  specific vendors.\n  In the future, filters showing convergence in behavior across multiple\n  implementations will be considered for inclusion in extended or core\n  conformance levels. Filter-specific configuration for such filters\n  is specified using the ExtensionRef field. `Type` should be set to\n  \"ExtensionRef\" for custom filters.\n\n\nImplementers are encouraged to define custom implementation types to\nextend the core API with implementation-specific behavior.\n\n\nIf a reference to a custom filter type cannot be resolved, the filter\nMUST NOT be skipped. Instead, requests that would have been processed by\nthat filter MUST receive a HTTP error response.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.",
+                            "enum": [
+                                "RequestHeaderModifier",
+                                "ResponseHeaderModifier",
+                                "RequestMirror",
+                                "RequestRedirect",
+                                "URLRewrite",
+                                "ExtensionRef"
+                            ],
+                            "type": "string"
+                        },
+                        "urlRewrite": {
+                            "description": "URLRewrite defines a schema for a filter that modifies a request during forwarding.\n\n\nSupport: Extended",
+                            "properties": {
+                                "hostname": {
+                                    "description": "Hostname is the value to be used to replace the Host header value during\nforwarding.\n\n\nSupport: Extended",
+                                    "maxLength": 253,
+                                    "minLength": 1,
+                                    "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                    "type": "string"
+                                },
+                                "path": {
+                                    "description": "Path defines a path rewrite.\n\n\nSupport: Extended",
+                                    "properties": {
+                                        "replaceFullPath": {
+                                            "description": "ReplaceFullPath specifies the value with which to replace the full path\nof a request during a rewrite or redirect.",
+                                            "maxLength": 1024,
+                                            "type": "string"
+                                        },
+                                        "replacePrefixMatch": {
+                                            "description": "ReplacePrefixMatch specifies the value with which to replace the prefix\nmatch of a request during a rewrite or redirect. For example, a request\nto \"/foo/bar\" with a prefix match of \"/foo\" and a ReplacePrefixMatch\nof \"/xyz\" would be modified to \"/xyz/bar\".\n\n\nNote that this matches the behavior of the PathPrefix match type. This\nmatches full path elements. A path element refers to the list of labels\nin the path split by the `/` separator. When specified, a trailing `/` is\nignored. For example, the paths `/abc`, `/abc/`, and `/abc/def` would all\nmatch the prefix `/abc`, but the path `/abcd` would not.\n\n\nReplacePrefixMatch is only compatible with a `PathPrefix` HTTPRouteMatch.\nUsing any other HTTPRouteMatch type on the same HTTPRouteRule will result in\nthe implementation setting the Accepted Condition for the Route to `status: False`.\n\n\nRequest Path | Prefix Match | Replace Prefix | Modified Path\n-------------|--------------|----------------|----------\n/foo/bar     | /foo         | /xyz           | /xyz/bar\n/foo/bar     | /foo         | /xyz/          | /xyz/bar\n/foo/bar     | /foo/        | /xyz           | /xyz/bar\n/foo/bar     | /foo/        | /xyz/          | /xyz/bar\n/foo         | /foo         | /xyz           | /xyz\n/foo/        | /foo         | /xyz           | /xyz/\n/foo/bar     | /foo         | <empty string> | /bar\n/foo/        | /foo         | <empty string> | /\n/foo         | /foo         | <empty string> | /\n/foo/        | /foo         | /              | /\n/foo         | /foo         | /              | /",
+                                            "maxLength": 1024,
+                                            "type": "string"
+                                        },
+                                        "type": {
+                                            "description": "Type defines the type of path modifier. Additional types may be\nadded in a future release of the API.\n\n\nNote that values may be added to this enum, implementations\nmust ensure that unknown values will not cause a crash.\n\n\nUnknown values here must result in the implementation setting the\nAccepted Condition for the Route to `status: False`, with a\nReason of `UnsupportedValue`.",
+                                            "enum": [
+                                                "ReplaceFullPath",
+                                                "ReplacePrefixMatch"
+                                            ],
+                                            "type": "string"
+                                        }
+                                    },
+                                    "required": [
+                                        "type"
+                                    ],
+                                    "type": "object",
+                                    "x-kubernetes-validations": [
+                                        {
+                                            "message": "replaceFullPath must be specified when type is set to 'ReplaceFullPath'",
+                                            "rule": "self.type == 'ReplaceFullPath' ? has(self.replaceFullPath) : true"
+                                        },
+                                        {
+                                            "message": "type must be 'ReplaceFullPath' when replaceFullPath is set",
+                                            "rule": "has(self.replaceFullPath) ? self.type == 'ReplaceFullPath' : true"
+                                        },
+                                        {
+                                            "message": "replacePrefixMatch must be specified when type is set to 'ReplacePrefixMatch'",
+                                            "rule": "self.type == 'ReplacePrefixMatch' ? has(self.replacePrefixMatch) : true"
+                                        },
+                                        {
+                                            "message": "type must be 'ReplacePrefixMatch' when replacePrefixMatch is set",
+                                            "rule": "has(self.replacePrefixMatch) ? self.type == 'ReplacePrefixMatch' : true"
+                                        }
+                                    ]
+                                }
+                            },
+                            "type": "object"
+                        }
+                    },
+                    "required": [
+                        "type"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "filter.requestHeaderModifier must be nil if the filter.type is not RequestHeaderModifier",
+                            "rule": "!(has(self.requestHeaderModifier) && self.type != 'RequestHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.requestHeaderModifier must be specified for RequestHeaderModifier filter.type",
+                            "rule": "!(!has(self.requestHeaderModifier) && self.type == 'RequestHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.responseHeaderModifier must be nil if the filter.type is not ResponseHeaderModifier",
+                            "rule": "!(has(self.responseHeaderModifier) && self.type != 'ResponseHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.responseHeaderModifier must be specified for ResponseHeaderModifier filter.type",
+                            "rule": "!(!has(self.responseHeaderModifier) && self.type == 'ResponseHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.requestMirror must be nil if the filter.type is not RequestMirror",
+                            "rule": "!(has(self.requestMirror) && self.type != 'RequestMirror')"
+                        },
+                        {
+                            "message": "filter.requestMirror must be specified for RequestMirror filter.type",
+                            "rule": "!(!has(self.requestMirror) && self.type == 'RequestMirror')"
+                        },
+                        {
+                            "message": "filter.requestRedirect must be nil if the filter.type is not RequestRedirect",
+                            "rule": "!(has(self.requestRedirect) && self.type != 'RequestRedirect')"
+                        },
+                        {
+                            "message": "filter.requestRedirect must be specified for RequestRedirect filter.type",
+                            "rule": "!(!has(self.requestRedirect) && self.type == 'RequestRedirect')"
+                        },
+                        {
+                            "message": "filter.urlRewrite must be nil if the filter.type is not URLRewrite",
+                            "rule": "!(has(self.urlRewrite) && self.type != 'URLRewrite')"
+                        },
+                        {
+                            "message": "filter.urlRewrite must be specified for URLRewrite filter.type",
+                            "rule": "!(!has(self.urlRewrite) && self.type == 'URLRewrite')"
+                        },
+                        {
+                            "message": "filter.extensionRef must be nil if the filter.type is not ExtensionRef",
+                            "rule": "!(has(self.extensionRef) && self.type != 'ExtensionRef')"
+                        },
+                        {
+                            "message": "filter.extensionRef must be specified for ExtensionRef filter.type",
+                            "rule": "!(!has(self.extensionRef) && self.type == 'ExtensionRef')"
+                        }
+                    ]
+                },
+                "maxItems": 16,
                 "type": "array",
+                "x-kubernetes-validations": [
+                    {
+                        "message": "May specify either httpRouteFilterRequestRedirect or httpRouteFilterRequestRewrite, but not both",
+                        "rule": "!(self.exists(f, f.type == 'RequestRedirect') && self.exists(f, f.type == 'URLRewrite'))"
+                    },
+                    {
+                        "message": "RequestHeaderModifier filter cannot be repeated",
+                        "rule": "self.filter(f, f.type == 'RequestHeaderModifier').size() <= 1"
+                    },
+                    {
+                        "message": "ResponseHeaderModifier filter cannot be repeated",
+                        "rule": "self.filter(f, f.type == 'ResponseHeaderModifier').size() <= 1"
+                    },
+                    {
+                        "message": "RequestRedirect filter cannot be repeated",
+                        "rule": "self.filter(f, f.type == 'RequestRedirect').size() <= 1"
+                    },
+                    {
+                        "message": "URLRewrite filter cannot be repeated",
+                        "rule": "self.filter(f, f.type == 'URLRewrite').size() <= 1"
+                    }
+                ]
+            },
+            "matches": {
+                "default": [
+                    {
+                        "path": {
+                            "type": "PathPrefix",
+                            "value": "/"
+                        }
+                    }
+                ],
+                "description": "Matches define conditions used for matching the rule against incoming\nHTTP requests. Each match is independent, i.e. this rule will be matched\nif **any** one of the matches is satisfied.\n\n\nFor example, take the following matches configuration:\n\n\n```\nmatches:\n- path:\n    value: \"/foo\"\n  headers:\n  - name: \"version\"\n    value: \"v2\"\n- path:\n    value: \"/v2/foo\"\n```\n\n\nFor a request to match against this rule, a request must satisfy\nEITHER of the two conditions:\n\n\n- path prefixed with `/foo` AND contains the header `version: v2`\n- path prefix of `/v2/foo`\n\n\nSee the documentation for HTTPRouteMatch on how to specify multiple\nmatch conditions that should be ANDed together.\n\n\nIf no matches are specified, the default is a prefix\npath match on \"/\", which has the effect of matching every\nHTTP request.\n\n\nProxy or Load Balancer routing configuration generated from HTTPRoutes\nMUST prioritize matches based on the following criteria, continuing on\nties. Across all rules specified on applicable Routes, precedence must be\ngiven to the match having:\n\n\n* \"Exact\" path match.\n* \"Prefix\" path match with largest number of characters.\n* Method match.\n* Largest number of header matches.\n* Largest number of query param matches.\n\n\nNote: The precedence of RegularExpression path matches are implementation-specific.\n\n\nIf ties still exist across multiple Routes, matching precedence MUST be\ndetermined in order of the following criteria, continuing on ties:\n\n\n* The oldest Route based on creation timestamp.\n* The Route appearing first in alphabetical order by\n  \"{namespace}/{name}\".\n\n\nIf ties still exist within an HTTPRoute, matching precedence MUST be granted\nto the FIRST matching rule (in list order) with a match meeting the above\ncriteria.\n\n\nWhen no rules matching a request have been successfully attached to the\nparent a request is coming from, a HTTP 404 status code MUST be returned.",
                 "items": {
+                    "description": "HTTPRouteMatch defines the predicate used to match requests to a given\naction. Multiple match types are ANDed together, i.e. the match will\nevaluate to true only if all conditions are satisfied.\n\n\nFor example, the match below will match a HTTP request only if its path\nstarts with `/foo` AND it contains the `version: v1` header:\n\n\n```\nmatch:\n\n\n\tpath:\n\t  value: \"/foo\"\n\theaders:\n\t- name: \"version\"\n\t  value \"v1\"\n\n\n```",
                     "properties": {
-                        # Path specifies a HTTP request path matcher.
-                        # If this field is not specified, a default prefix match on the “/” path is provided. # noqa
+                        "headers": {
+                            "description": "Headers specifies HTTP request header matchers. Multiple match values are\nANDed together, meaning, a request must match all the specified headers\nto select the route.",
+                            "items": {
+                                "description": "HTTPHeaderMatch describes how to select a HTTP route by matching HTTP request\nheaders.",
+                                "properties": {
+                                    "name": {
+                                        "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, only the first\nentry with an equivalent name MUST be considered for a match. Subsequent\nentries with an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.\n\n\nWhen a header is repeated in an HTTP request, it is\nimplementation-specific behavior as to how this is represented.\nGenerally, proxies should follow the guidance from the RFC:\nhttps://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.2 regarding\nprocessing a repeated header, with special handling for \"Set-Cookie\".",
+                                        "maxLength": 256,
+                                        "minLength": 1,
+                                        "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                        "type": "string"
+                                    },
+                                    "type": {
+                                        "default": "Exact",
+                                        "description": "Type specifies how to match against the value of the header.\n\n\nSupport: Core (Exact)\n\n\nSupport: Implementation-specific (RegularExpression)\n\n\nSince RegularExpression HeaderMatchType has implementation-specific\nconformance, implementations can support POSIX, PCRE or any other dialects\nof regular expressions. Please read the implementation's documentation to\ndetermine the supported dialect.",
+                                        "enum": [
+                                            "Exact",
+                                            "RegularExpression"
+                                        ],
+                                        "type": "string"
+                                    },
+                                    "value": {
+                                        "description": "Value is the value of HTTP Header to be matched.",
+                                        "maxLength": 4096,
+                                        "minLength": 1,
+                                        "type": "string"
+                                    }
+                                },
+                                "required": [
+                                    "name",
+                                    "value"
+                                ],
+                                "type": "object"
+                            },
+                            "maxItems": 16,
+                            "type": "array",
+                            "x-kubernetes-list-map-keys": [
+                                "name"
+                            ],
+                            "x-kubernetes-list-type": "map"
+                        },
+                        "method": {
+                            "description": "Method specifies HTTP method matcher.\nWhen specified, this route will be matched only if the request has the\nspecified method.\n\n\nSupport: Extended",
+                            "enum": [
+                                "GET",
+                                "HEAD",
+                                "POST",
+                                "PUT",
+                                "DELETE",
+                                "CONNECT",
+                                "OPTIONS",
+                                "TRACE",
+                                "PATCH"
+                            ],
+                            "type": "string"
+                        },
                         "path": {
-                            "type": "object",
+                            "default": {
+                                "type": "PathPrefix",
+                                "value": "/"
+                            },
+                            "description": "Path specifies a HTTP request path matcher. If this field is not\nspecified, a default prefix match on the \"/\" path is provided.",
                             "properties": {
                                 "type": {
-                                    "type": "string",
-                                    "enum": ["Exact", "PathPrefix", "RegularExpression"],
-                                    "default": "PathPrefix"
+                                    "default": "PathPrefix",
+                                    "description": "Type specifies how to match against the path Value.\n\n\nSupport: Core (Exact, PathPrefix)\n\n\nSupport: Implementation-specific (RegularExpression)",
+                                    "enum": [
+                                        "Exact",
+                                        "PathPrefix",
+                                        "RegularExpression"
+                                    ],
+                                    "type": "string"
                                 },
-                                "value": {"type": "string"}
+                                "value": {
+                                    "default": "/",
+                                    "description": "Value of the HTTP path to match against.",
+                                    "maxLength": 1024,
+                                    "type": "string"
+                                }
                             },
-                            "additionalProperties": False
+                            "type": "object",
+                            "x-kubernetes-validations": [
+                                {
+                                    "message": "value must be an absolute path and start with '/' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? self.value.startsWith('/') : true"
+                                },
+                                {
+                                    "message": "must not contain '//' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.contains('//') : true"
+                                },
+                                {
+                                    "message": "must not contain '/./' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.contains('/./') : true"
+                                },
+                                {
+                                    "message": "must not contain '/../' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.contains('/../') : true"
+                                },
+                                {
+                                    "message": "must not contain '%2f' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.contains('%2f') : true"
+                                },
+                                {
+                                    "message": "must not contain '%2F' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.contains('%2F') : true"
+                                },
+                                {
+                                    "message": "must not contain '#' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.contains('#') : true"
+                                },
+                                {
+                                    "message": "must not end with '/..' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.endsWith('/..') : true"
+                                },
+                                {
+                                    "message": "must not end with '/.' when type one of ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? !self.value.endsWith('/.') : true"
+                                },
+                                {
+                                    "message": "type must be one of ['Exact', 'PathPrefix', 'RegularExpression']",
+                                    "rule": "self.type in ['Exact','PathPrefix'] || self.type == 'RegularExpression'"
+                                },
+                                {
+                                    "message": "must only contain valid characters (matching ^(?:[-A-Za-z0-9/._~!$&'()*+,;=:@]|[%][0-9a-fA-F]{2})+$) for types ['Exact', 'PathPrefix']",
+                                    "rule": "(self.type in ['Exact','PathPrefix']) ? self.value.matches(r\"\"\"^(?:[-A-Za-z0-9/._~!$&'()*+,;=:@]|[%][0-9a-fA-F]{2})+$\"\"\") : true"
+                                }
+                            ]
                         },
-                        # Headers specifies HTTP request header matchers. Multiple match values are ANDed together, # noqa
-                        # meaning, a request must match all the specified headers to select the route. # noqa
-                        # gateway.networking.k8s.io/v1beta1.HTTPHeaderMatch
-                        # More info: https: // gateway-api.sigs.k8s.io/references/spec /
-                        "headers": {
-                            "type": "array",
+                        "queryParams": {
+                            "description": "QueryParams specifies HTTP query parameter matchers. Multiple match\nvalues are ANDed together, meaning, a request must match all the\nspecified query parameters to select the route.\n\n\nSupport: Extended",
                             "items": {
+                                "description": "HTTPQueryParamMatch describes how to select a HTTP route by matching HTTP\nquery parameters.",
                                 "properties": {
+                                    "name": {
+                                        "description": "Name is the name of the HTTP query param to be matched. This must be an\nexact string match. (See\nhttps://tools.ietf.org/html/rfc7230#section-2.7.3).\n\n\nIf multiple entries specify equivalent query param names, only the first\nentry with an equivalent name MUST be considered for a match. Subsequent\nentries with an equivalent query param name MUST be ignored.\n\n\nIf a query param is repeated in an HTTP request, the behavior is\npurposely left undefined, since different data planes have different\ncapabilities. However, it is *recommended* that implementations should\nmatch against the first value of the param if the data plane supports it,\nas this behavior is expected in other load balancing contexts outside of\nthe Gateway API.\n\n\nUsers SHOULD NOT route traffic based on repeated query params to guard\nthemselves against potential differences in the implementations.",
+                                        "maxLength": 256,
+                                        "minLength": 1,
+                                        "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                        "type": "string"
+                                    },
                                     "type": {
-                                        "type": "string",
-                                        "enum": ["Exact", "RegularExpression"],
-                                        "default": "Exact"
+                                        "default": "Exact",
+                                        "description": "Type specifies how to match against the value of the query parameter.\n\n\nSupport: Extended (Exact)\n\n\nSupport: Implementation-specific (RegularExpression)\n\n\nSince RegularExpression QueryParamMatchType has Implementation-specific\nconformance, implementations can support POSIX, PCRE or any other\ndialects of regular expressions. Please read the implementation's\ndocumentation to determine the supported dialect.",
+                                        "enum": [
+                                            "Exact",
+                                            "RegularExpression"
+                                        ],
+                                        "type": "string"
                                     },
-                                    "name": {"type": "string"},
-                                    "value": {"type": "string"}
+                                    "value": {
+                                        "description": "Value is the value of HTTP query param to be matched.",
+                                        "maxLength": 1024,
+                                        "minLength": 1,
+                                        "type": "string"
+                                    }
                                 },
-                                "additionalProperties": False
+                                "required": [
+                                    "name",
+                                    "value"
+                                ],
+                                "type": "object"
+                            },
+                            "maxItems": 16,
+                            "type": "array",
+                            "x-kubernetes-list-map-keys": [
+                                "name"
+                            ],
+                            "x-kubernetes-list-type": "map"
+                        }
+                    },
+                    "type": "object"
+                },
+                "maxItems": 8,
+                "type": "array"
+            },
+            "sessionPersistence": {
+                "description": "SessionPersistence defines and configures session persistence\nfor the route rule.\n\n\nSupport: Extended\n\n\n",
+                "properties": {
+                    "absoluteTimeout": {
+                        "description": "AbsoluteTimeout defines the absolute timeout of the persistent\nsession. Once the AbsoluteTimeout duration has elapsed, the\nsession becomes invalid.\n\n\nSupport: Extended",
+                        "pattern": "^([0-9]{1,5}(h|m|s|ms)){1,4}$",
+                        "type": "string"
+                    },
+                    "cookieConfig": {
+                        "description": "CookieConfig provides configuration settings that are specific\nto cookie-based session persistence.\n\n\nSupport: Core",
+                        "properties": {
+                            "lifetimeType": {
+                                "default": "Session",
+                                "description": "LifetimeType specifies whether the cookie has a permanent or\nsession-based lifetime. A permanent cookie persists until its\nspecified expiry time, defined by the Expires or Max-Age cookie\nattributes, while a session cookie is deleted when the current\nsession ends.\n\n\nWhen set to \"Permanent\", AbsoluteTimeout indicates the\ncookie's lifetime via the Expires or Max-Age cookie attributes\nand is required.\n\n\nWhen set to \"Session\", AbsoluteTimeout indicates the\nabsolute lifetime of the cookie tracked by the gateway and\nis optional.\n\n\nSupport: Core for \"Session\" type\n\n\nSupport: Extended for \"Permanent\" type",
+                                "enum": [
+                                    "Permanent",
+                                    "Session"
+                                ],
+                                "type": "string"
                             }
                         },
-                        # QueryParams specifies HTTP query parameter matchers. Multiple match values are ANDed together, # noqa
-                        # meaning, a request must match all the specified query parameters to select the route. # noqa
-                        "queryParams": {
-                            "type": "array",
+                        "type": "object"
+                    },
+                    "idleTimeout": {
+                        "description": "IdleTimeout defines the idle timeout of the persistent session.\nOnce the session has been idle for more than the specified\nIdleTimeout duration, the session becomes invalid.\n\n\nSupport: Extended",
+                        "pattern": "^([0-9]{1,5}(h|m|s|ms)){1,4}$",
+                        "type": "string"
+                    },
+                    "sessionName": {
+                        "description": "SessionName defines the name of the persistent session token\nwhich may be reflected in the cookie or the header. Users\nshould avoid reusing session names to prevent unintended\nconsequences, such as rejection or unpredictable behavior.\n\n\nSupport: Implementation-specific",
+                        "maxLength": 128,
+                        "type": "string"
+                    },
+                    "type": {
+                        "default": "Cookie",
+                        "description": "Type defines the type of session persistence such as through\nthe use a header or cookie. Defaults to cookie based session\npersistence.\n\n\nSupport: Core for \"Cookie\" type\n\n\nSupport: Extended for \"Header\" type",
+                        "enum": [
+                            "Cookie",
+                            "Header"
+                        ],
+                        "type": "string"
+                    }
+                },
+                "type": "object",
+                "x-kubernetes-validations": [
+                    {
+                        "message": "AbsoluteTimeout must be specified when cookie lifetimeType is Permanent",
+                        "rule": "!has(self.cookieConfig.lifetimeType) || self.cookieConfig.lifetimeType != 'Permanent' || has(self.absoluteTimeout)"
+                    }
+                ]
+            },
+            "timeouts": {
+                "description": "Timeouts defines the timeouts that can be configured for an HTTP request.\n\n\nSupport: Extended\n\n\n",
+                "properties": {
+                    "backendRequest": {
+                        "description": "BackendRequest specifies a timeout for an individual request from the gateway\nto a backend. This covers the time from when the request first starts being\nsent from the gateway to when the full response has been received from the backend.\n\n\nSetting a timeout to the zero duration (e.g. \"0s\") SHOULD disable the timeout\ncompletely. Implementations that cannot completely disable the timeout MUST\ninstead interpret the zero duration as the longest possible value to which\nthe timeout can be set.\n\n\nAn entire client HTTP transaction with a gateway, covered by the Request timeout,\nmay result in more than one call from the gateway to the destination backend,\nfor example, if automatic retries are supported.\n\n\nBecause the Request timeout encompasses the BackendRequest timeout, the value of\nBackendRequest must be <= the value of Request timeout.\n\n\nSupport: Extended",
+                        "pattern": "^([0-9]{1,5}(h|m|s|ms)){1,4}$",
+                        "type": "string"
+                    },
+                    "request": {
+                        "description": "Request specifies the maximum duration for a gateway to respond to an HTTP request.\nIf the gateway has not been able to respond before this deadline is met, the gateway\nMUST return a timeout error.\n\n\nFor example, setting the `rules.timeouts.request` field to the value `10s` in an\n`HTTPRoute` will cause a timeout if a client request is taking longer than 10 seconds\nto complete.\n\n\nSetting a timeout to the zero duration (e.g. \"0s\") SHOULD disable the timeout\ncompletely. Implementations that cannot completely disable the timeout MUST\ninstead interpret the zero duration as the longest possible value to which\nthe timeout can be set.\n\n\nThis timeout is intended to cover as close to the whole request-response transaction\nas possible although an implementation MAY choose to start the timeout after the entire\nrequest stream has been received instead of immediately after the transaction is\ninitiated by the client.\n\n\nWhen this field is unspecified, request timeout behavior is implementation-specific.\n\n\nSupport: Extended",
+                        "pattern": "^([0-9]{1,5}(h|m|s|ms)){1,4}$",
+                        "type": "string"
+                    }
+                },
+                "type": "object",
+                "x-kubernetes-validations": [
+                    {
+                        "message": "backendRequest timeout cannot be longer than request timeout",
+                        "rule": "!(has(self.request) && has(self.backendRequest) && duration(self.request) != duration('0s') && duration(self.backendRequest) > duration(self.request))"
+                    }
+                ]
+            }
+        },
+        "type": "object",
+        "x-kubernetes-validations": [
+            {
+                "message": "RequestRedirect filter must not be used together with backendRefs",
+                "rule": "(has(self.backendRefs) && size(self.backendRefs) > 0) ? (!has(self.filters) || self.filters.all(f, !has(f.requestRedirect))): true"
+            },
+            {
+                "message": "When using RequestRedirect filter with path.replacePrefixMatch, exactly one PathPrefix match must be specified",
+                "rule": "(has(self.filters) && self.filters.exists_one(f, has(f.requestRedirect) && has(f.requestRedirect.path) && f.requestRedirect.path.type == 'ReplacePrefixMatch' && has(f.requestRedirect.path.replacePrefixMatch))) ? ((size(self.matches) != 1 || !has(self.matches[0].path) || self.matches[0].path.type != 'PathPrefix') ? false : true) : true"
+            },
+            {
+                "message": "When using URLRewrite filter with path.replacePrefixMatch, exactly one PathPrefix match must be specified",
+                "rule": "(has(self.filters) && self.filters.exists_one(f, has(f.urlRewrite) && has(f.urlRewrite.path) && f.urlRewrite.path.type == 'ReplacePrefixMatch' && has(f.urlRewrite.path.replacePrefixMatch))) ? ((size(self.matches) != 1 || !has(self.matches[0].path) || self.matches[0].path.type != 'PathPrefix') ? false : true) : true"
+            },
+            {
+                "message": "Within backendRefs, when using RequestRedirect filter with path.replacePrefixMatch, exactly one PathPrefix match must be specified",
+                "rule": "(has(self.backendRefs) && self.backendRefs.exists_one(b, (has(b.filters) && b.filters.exists_one(f, has(f.requestRedirect) && has(f.requestRedirect.path) && f.requestRedirect.path.type == 'ReplacePrefixMatch' && has(f.requestRedirect.path.replacePrefixMatch))) )) ? ((size(self.matches) != 1 || !has(self.matches[0].path) || self.matches[0].path.type != 'PathPrefix') ? false : true) : true"
+            },
+            {
+                "message": "Within backendRefs, When using URLRewrite filter with path.replacePrefixMatch, exactly one PathPrefix match must be specified",
+                "rule": "(has(self.backendRefs) && self.backendRefs.exists_one(b, (has(b.filters) && b.filters.exists_one(f, has(f.urlRewrite) && has(f.urlRewrite.path) && f.urlRewrite.path.type == 'ReplacePrefixMatch' && has(f.urlRewrite.path.replacePrefixMatch))) )) ? ((size(self.matches) != 1 || !has(self.matches[0].path) || self.matches[0].path.type != 'PathPrefix') ? false : true) : true"
+            }
+        ]
+    },
+    "maxItems": 16,
+    "type": "array"
+}
+
+GRPC_RULES_SCHEMA = {
+    "description": "Rules are a list of GRPC matchers, filters and actions.",
+    "items": {
+        "description": "GRPCRouteRule defines the semantics for matching a gRPC request based on\nconditions (matches), processing it (filters), and forwarding the request to\nan API object (backendRefs).",
+        "properties": {
+            "backendRefs": {
+                "description": "BackendRefs defines the backend(s) where matching requests should be\nsent.\n\n\nFailure behavior here depends on how many BackendRefs are specified and\nhow many are invalid.\n\n\nIf *all* entries in BackendRefs are invalid, and there are also no filters\nspecified in this route rule, *all* traffic which matches this rule MUST\nreceive an `UNAVAILABLE` status.\n\n\nSee the GRPCBackendRef definition for the rules about what makes a single\nGRPCBackendRef invalid.\n\n\nWhen a GRPCBackendRef is invalid, `UNAVAILABLE` statuses MUST be returned for\nrequests that would have otherwise been routed to an invalid backend. If\nmultiple backends are specified, and some are invalid, the proportion of\nrequests that would otherwise have been routed to an invalid backend\nMUST receive an `UNAVAILABLE` status.\n\n\nFor example, if two backends are specified with equal weights, and one is\ninvalid, 50 percent of traffic MUST receive an `UNAVAILABLE` status.\nImplementations may choose how that 50 percent is determined.\n\n\nSupport: Core for Kubernetes Service\n\n\nSupport: Implementation-specific for any other resource\n\n\nSupport for weight: Core",
+                "items": {
+                    "description": "GRPCBackendRef defines how a GRPCRoute forwards a gRPC request.\n\n\nNote that when a namespace different than the local namespace is specified, a\nReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\n<gateway:experimental:description>\n\n\nWhen the BackendRef points to a Kubernetes Service, implementations SHOULD\nhonor the appProtocol field if it is set for the target Service Port.\n\n\nImplementations supporting appProtocol SHOULD recognize the Kubernetes\nStandard Application Protocols defined in KEP-3726.\n\n\nIf a Service appProtocol isn't specified, an implementation MAY infer the\nbackend protocol through its own means. Implementations MAY infer the\nprotocol from the Route type referring to the backend Service.\n\n\nIf a Route is not able to send traffic to the backend using the specified\nprotocol then the backend is considered invalid. Implementations MUST set the\n\"ResolvedRefs\" condition to \"False\" with the \"UnsupportedProtocol\" reason.\n\n\n</gateway:experimental:description>",
+                    "properties": {
+                        "filters": {
+                            "description": "Filters defined at this level MUST be executed if and only if the\nrequest is being forwarded to the backend defined here.\n\n\nSupport: Implementation-specific (For broader support of filters, use the\nFilters field in GRPCRouteRule.)",
                             "items": {
+                                "description": "GRPCRouteFilter defines processing steps that must be completed during the\nrequest or response lifecycle. GRPCRouteFilters are meant as an extension\npoint to express processing that may be done in Gateway implementations. Some\nexamples include request or response modification, implementing\nauthentication strategies, rate-limiting, and traffic shaping. API\nguarantee/conformance is defined based on the type of the filter.",
                                 "properties": {
+                                    "extensionRef": {
+                                        "description": "ExtensionRef is an optional, implementation-specific extension to the\n\"filter\" behavior.  For example, resource \"myroutefilter\" in group\n\"networking.example.net\"). ExtensionRef MUST NOT be used for core and\nextended filters.\n\n\nSupport: Implementation-specific\n\n\nThis filter can be used multiple times within the same rule.",
+                                        "properties": {
+                                            "group": {
+                                                "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                                "maxLength": 253,
+                                                "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                                "type": "string"
+                                            },
+                                            "kind": {
+                                                "description": "Kind is kind of the referent. For example \"HTTPRoute\" or \"Service\".",
+                                                "maxLength": 63,
+                                                "minLength": 1,
+                                                "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                                "type": "string"
+                                            },
+                                            "name": {
+                                                "description": "Name is the name of the referent.",
+                                                "maxLength": 253,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "group",
+                                            "kind",
+                                            "name"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "requestHeaderModifier": {
+                                        "description": "RequestHeaderModifier defines a schema for a filter that modifies request\nheaders.\n\n\nSupport: Core",
+                                        "properties": {
+                                            "add": {
+                                                "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            },
+                                            "remove": {
+                                                "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                                "items": {
+                                                    "type": "string"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-type": "set"
+                                            },
+                                            "set": {
+                                                "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            }
+                                        },
+                                        "type": "object"
+                                    },
+                                    "requestMirror": {
+                                        "description": "RequestMirror defines a schema for a filter that mirrors requests.\nRequests are sent to the specified destination, but responses from\nthat destination are ignored.\n\n\nThis filter can be used multiple times within the same rule. Note that\nnot all implementations will be able to support mirroring to multiple\nbackends.\n\n\nSupport: Extended",
+                                        "properties": {
+                                            "backendRef": {
+                                                "description": "BackendRef references a resource where mirrored requests are sent.\n\n\nMirrored requests must be sent only to a single destination endpoint\nwithin this BackendRef, irrespective of how many endpoints are present\nwithin this BackendRef.\n\n\nIf the referent cannot be found, this BackendRef is invalid and must be\ndropped from the Gateway. The controller must ensure the \"ResolvedRefs\"\ncondition on the Route status is set to `status: False` and not configure\nthis backend in the underlying implementation.\n\n\nIf there is a cross-namespace reference to an *existing* object\nthat is not allowed by a ReferenceGrant, the controller must ensure the\n\"ResolvedRefs\"  condition on the Route is set to `status: False`,\nwith the \"RefNotPermitted\" reason and not configure this backend in the\nunderlying implementation.\n\n\nIn either error case, the Message of the `ResolvedRefs` Condition\nshould be used to provide more detail about the problem.\n\n\nSupport: Extended for Kubernetes Service\n\n\nSupport: Implementation-specific for any other resource",
+                                                "properties": {
+                                                    "group": {
+                                                        "default": "",
+                                                        "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                                        "maxLength": 253,
+                                                        "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                                        "type": "string"
+                                                    },
+                                                    "kind": {
+                                                        "default": "Service",
+                                                        "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                                                        "maxLength": 63,
+                                                        "minLength": 1,
+                                                        "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                                        "type": "string"
+                                                    },
+                                                    "name": {
+                                                        "description": "Name is the name of the referent.",
+                                                        "maxLength": 253,
+                                                        "minLength": 1,
+                                                        "type": "string"
+                                                    },
+                                                    "namespace": {
+                                                        "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                                                        "maxLength": 63,
+                                                        "minLength": 1,
+                                                        "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                                                        "type": "string"
+                                                    },
+                                                    "port": {
+                                                        "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                                                        "format": "int32",
+                                                        "maximum": 65535,
+                                                        "minimum": 1,
+                                                        "type": "integer"
+                                                    }
+                                                },
+                                                "required": [
+                                                    "name"
+                                                ],
+                                                "type": "object",
+                                                "x-kubernetes-validations": [
+                                                    {
+                                                        "message": "Must have port for Service reference",
+                                                        "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                                                    }
+                                                ]
+                                            }
+                                        },
+                                        "required": [
+                                            "backendRef"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "responseHeaderModifier": {
+                                        "description": "ResponseHeaderModifier defines a schema for a filter that modifies response\nheaders.\n\n\nSupport: Extended",
+                                        "properties": {
+                                            "add": {
+                                                "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            },
+                                            "remove": {
+                                                "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                                "items": {
+                                                    "type": "string"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-type": "set"
+                                            },
+                                            "set": {
+                                                "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                                "items": {
+                                                    "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                                    "properties": {
+                                                        "name": {
+                                                            "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                            "maxLength": 256,
+                                                            "minLength": 1,
+                                                            "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                            "type": "string"
+                                                        },
+                                                        "value": {
+                                                            "description": "Value is the value of HTTP Header to be matched.",
+                                                            "maxLength": 4096,
+                                                            "minLength": 1,
+                                                            "type": "string"
+                                                        }
+                                                    },
+                                                    "required": [
+                                                        "name",
+                                                        "value"
+                                                    ],
+                                                    "type": "object"
+                                                },
+                                                "maxItems": 16,
+                                                "type": "array",
+                                                "x-kubernetes-list-map-keys": [
+                                                    "name"
+                                                ],
+                                                "x-kubernetes-list-type": "map"
+                                            }
+                                        },
+                                        "type": "object"
+                                    },
                                     "type": {
-                                        "type": "string",
-                                        "enum": ["Exact", "RegularExpression"],
-                                        "default": "Exact"
+                                        "description": "Type identifies the type of filter to apply. As with other API fields,\ntypes are classified into three conformance levels:\n\n\n- Core: Filter types and their corresponding configuration defined by\n  \"Support: Core\" in this package, e.g. \"RequestHeaderModifier\". All\n  implementations supporting GRPCRoute MUST support core filters.\n\n\n- Extended: Filter types and their corresponding configuration defined by\n  \"Support: Extended\" in this package, e.g. \"RequestMirror\". Implementers\n  are encouraged to support extended filters.\n\n\n- Implementation-specific: Filters that are defined and supported by specific vendors.\n  In the future, filters showing convergence in behavior across multiple\n  implementations will be considered for inclusion in extended or core\n  conformance levels. Filter-specific configuration for such filters\n  is specified using the ExtensionRef field. `Type` MUST be set to\n  \"ExtensionRef\" for custom filters.\n\n\nImplementers are encouraged to define custom implementation types to\nextend the core API with implementation-specific behavior.\n\n\nIf a reference to a custom filter type cannot be resolved, the filter\nMUST NOT be skipped. Instead, requests that would have been processed by\nthat filter MUST receive a HTTP error response.\n\n\n",
+                                        "enum": [
+                                            "ResponseHeaderModifier",
+                                            "RequestHeaderModifier",
+                                            "RequestMirror",
+                                            "ExtensionRef"
+                                        ],
+                                        "type": "string"
+                                    }
+                                },
+                                "required": [
+                                    "type"
+                                ],
+                                "type": "object",
+                                "x-kubernetes-validations": [
+                                    {
+                                        "message": "filter.requestHeaderModifier must be nil if the filter.type is not RequestHeaderModifier",
+                                        "rule": "!(has(self.requestHeaderModifier) && self.type != 'RequestHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.requestHeaderModifier must be specified for RequestHeaderModifier filter.type",
+                                        "rule": "!(!has(self.requestHeaderModifier) && self.type == 'RequestHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.responseHeaderModifier must be nil if the filter.type is not ResponseHeaderModifier",
+                                        "rule": "!(has(self.responseHeaderModifier) && self.type != 'ResponseHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.responseHeaderModifier must be specified for ResponseHeaderModifier filter.type",
+                                        "rule": "!(!has(self.responseHeaderModifier) && self.type == 'ResponseHeaderModifier')"
+                                    },
+                                    {
+                                        "message": "filter.requestMirror must be nil if the filter.type is not RequestMirror",
+                                        "rule": "!(has(self.requestMirror) && self.type != 'RequestMirror')"
                                     },
-                                    "name": {"type": "string"},
-                                    "value": {"type": "string"}
+                                    {
+                                        "message": "filter.requestMirror must be specified for RequestMirror filter.type",
+                                        "rule": "!(!has(self.requestMirror) && self.type == 'RequestMirror')"
+                                    },
+                                    {
+                                        "message": "filter.extensionRef must be nil if the filter.type is not ExtensionRef",
+                                        "rule": "!(has(self.extensionRef) && self.type != 'ExtensionRef')"
+                                    },
+                                    {
+                                        "message": "filter.extensionRef must be specified for ExtensionRef filter.type",
+                                        "rule": "!(!has(self.extensionRef) && self.type == 'ExtensionRef')"
+                                    }
+                                ]
+                            },
+                            "maxItems": 16,
+                            "type": "array",
+                            "x-kubernetes-validations": [
+                                {
+                                    "message": "RequestHeaderModifier filter cannot be repeated",
+                                    "rule": "self.filter(f, f.type == 'RequestHeaderModifier').size() <= 1"
                                 },
-                                "additionalProperties": False
-                            }
+                                {
+                                    "message": "ResponseHeaderModifier filter cannot be repeated",
+                                    "rule": "self.filter(f, f.type == 'ResponseHeaderModifier').size() <= 1"
+                                }
+                            ]
                         },
-                        "method": {
-                            "type": "string",
-                            "enum": ["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"],  # noqa
+                        "group": {
+                            "default": "",
+                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                            "maxLength": 253,
+                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                            "type": "string"
+                        },
+                        "kind": {
+                            "default": "Service",
+                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                            "type": "string"
+                        },
+                        "name": {
+                            "description": "Name is the name of the referent.",
+                            "maxLength": 253,
+                            "minLength": 1,
+                            "type": "string"
+                        },
+                        "namespace": {
+                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                            "maxLength": 63,
+                            "minLength": 1,
+                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                            "type": "string"
+                        },
+                        "port": {
+                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                            "format": "int32",
+                            "maximum": 65535,
+                            "minimum": 1,
+                            "type": "integer"
+                        },
+                        "weight": {
+                            "default": 1,
+                            "description": "Weight specifies the proportion of requests forwarded to the referenced\nbackend. This is computed as weight/(sum of all weights in this\nBackendRefs list). For non-zero values, there may be some epsilon from\nthe exact proportion defined here depending on the precision an\nimplementation supports. Weight is not a percentage and the sum of\nweights does not need to equal 100.\n\n\nIf only one backend is specified and it has a weight greater than 0, 100%\nof the traffic is forwarded to that backend. If weight is set to 0, no\ntraffic should be forwarded for this entry. If unspecified, weight\ndefaults to 1.\n\n\nSupport for this field varies based on the context where used.",
+                            "format": "int32",
+                            "maximum": 1000000,
+                            "minimum": 0,
+                            "type": "integer"
                         }
                     },
-                    "additionalProperties": False
-                }
+                    "required": [
+                        "name"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "Must have port for Service reference",
+                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                        }
+                    ]
+                },
+                "maxItems": 16,
+                "type": "array"
             },
-            # Filters define the filters that are applied to requests that match this rule.
-            # More info: https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1beta1.HTTPRouteFilter # noqa
             "filters": {
-                "type": "array",
+                "description": "Filters define the filters that are applied to requests that match\nthis rule.\n\n\nThe effects of ordering of multiple behaviors are currently unspecified.\nThis can change in the future based on feedback during the alpha stage.\n\n\nConformance-levels at this level are defined based on the type of filter:\n\n\n- ALL core filters MUST be supported by all implementations that support\n  GRPCRoute.\n- Implementers are encouraged to support extended filters.\n- Implementation-specific custom filters have no API guarantees across\n  implementations.\n\n\nSpecifying the same filter multiple times is not supported unless explicitly\nindicated in the filter.\n\n\nIf an implementation can not support a combination of filters, it must clearly\ndocument that limitation. In cases where incompatible or unsupported\nfilters are specified and cause the `Accepted` condition to be set to status\n`False`, implementations may use the `IncompatibleFilters` reason to specify\nthis configuration error.\n\n\nSupport: Core",
                 "items": {
-                    "properties": FILTER_SCHEMA,
-                    "additionalProperties": False
+                    "description": "GRPCRouteFilter defines processing steps that must be completed during the\nrequest or response lifecycle. GRPCRouteFilters are meant as an extension\npoint to express processing that may be done in Gateway implementations. Some\nexamples include request or response modification, implementing\nauthentication strategies, rate-limiting, and traffic shaping. API\nguarantee/conformance is defined based on the type of the filter.",
+                    "properties": {
+                        "extensionRef": {
+                            "description": "ExtensionRef is an optional, implementation-specific extension to the\n\"filter\" behavior.  For example, resource \"myroutefilter\" in group\n\"networking.example.net\"). ExtensionRef MUST NOT be used for core and\nextended filters.\n\n\nSupport: Implementation-specific\n\n\nThis filter can be used multiple times within the same rule.",
+                            "properties": {
+                                "group": {
+                                    "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                    "maxLength": 253,
+                                    "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                    "type": "string"
+                                },
+                                "kind": {
+                                    "description": "Kind is kind of the referent. For example \"HTTPRoute\" or \"Service\".",
+                                    "maxLength": 63,
+                                    "minLength": 1,
+                                    "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                    "type": "string"
+                                },
+                                "name": {
+                                    "description": "Name is the name of the referent.",
+                                    "maxLength": 253,
+                                    "minLength": 1,
+                                    "type": "string"
+                                }
+                            },
+                            "required": [
+                                "group",
+                                "kind",
+                                "name"
+                            ],
+                            "type": "object"
+                        },
+                        "requestHeaderModifier": {
+                            "description": "RequestHeaderModifier defines a schema for a filter that modifies request\nheaders.\n\n\nSupport: Core",
+                            "properties": {
+                                "add": {
+                                    "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                },
+                                "remove": {
+                                    "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-type": "set"
+                                },
+                                "set": {
+                                    "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                }
+                            },
+                            "type": "object"
+                        },
+                        "requestMirror": {
+                            "description": "RequestMirror defines a schema for a filter that mirrors requests.\nRequests are sent to the specified destination, but responses from\nthat destination are ignored.\n\n\nThis filter can be used multiple times within the same rule. Note that\nnot all implementations will be able to support mirroring to multiple\nbackends.\n\n\nSupport: Extended",
+                            "properties": {
+                                "backendRef": {
+                                    "description": "BackendRef references a resource where mirrored requests are sent.\n\n\nMirrored requests must be sent only to a single destination endpoint\nwithin this BackendRef, irrespective of how many endpoints are present\nwithin this BackendRef.\n\n\nIf the referent cannot be found, this BackendRef is invalid and must be\ndropped from the Gateway. The controller must ensure the \"ResolvedRefs\"\ncondition on the Route status is set to `status: False` and not configure\nthis backend in the underlying implementation.\n\n\nIf there is a cross-namespace reference to an *existing* object\nthat is not allowed by a ReferenceGrant, the controller must ensure the\n\"ResolvedRefs\"  condition on the Route is set to `status: False`,\nwith the \"RefNotPermitted\" reason and not configure this backend in the\nunderlying implementation.\n\n\nIn either error case, the Message of the `ResolvedRefs` Condition\nshould be used to provide more detail about the problem.\n\n\nSupport: Extended for Kubernetes Service\n\n\nSupport: Implementation-specific for any other resource",
+                                    "properties": {
+                                        "group": {
+                                            "default": "",
+                                            "description": "Group is the group of the referent. For example, \"gateway.networking.k8s.io\".\nWhen unspecified or empty string, core API group is inferred.",
+                                            "maxLength": 253,
+                                            "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
+                                            "type": "string"
+                                        },
+                                        "kind": {
+                                            "default": "Service",
+                                            "description": "Kind is the Kubernetes resource kind of the referent. For example\n\"Service\".\n\n\nDefaults to \"Service\" when not specified.\n\n\nExternalName services can refer to CNAME DNS records that may live\noutside of the cluster and as such are difficult to reason about in\nterms of conformance. They also may not be safe to forward to (see\nCVE-2021-25740 for more information). Implementations SHOULD NOT\nsupport ExternalName Services.\n\n\nSupport: Core (Services with a type other than ExternalName)\n\n\nSupport: Implementation-specific (Services with type ExternalName)",
+                                            "maxLength": 63,
+                                            "minLength": 1,
+                                            "pattern": "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$",
+                                            "type": "string"
+                                        },
+                                        "name": {
+                                            "description": "Name is the name of the referent.",
+                                            "maxLength": 253,
+                                            "minLength": 1,
+                                            "type": "string"
+                                        },
+                                        "namespace": {
+                                            "description": "Namespace is the namespace of the backend. When unspecified, the local\nnamespace is inferred.\n\n\nNote that when a namespace different than the local namespace is specified,\na ReferenceGrant object is required in the referent namespace to allow that\nnamespace's owner to accept the reference. See the ReferenceGrant\ndocumentation for details.\n\n\nSupport: Core",
+                                            "maxLength": 63,
+                                            "minLength": 1,
+                                            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                                            "type": "string"
+                                        },
+                                        "port": {
+                                            "description": "Port specifies the destination port number to use for this resource.\nPort is required when the referent is a Kubernetes Service. In this\ncase, the port number is the service port number, not the target port.\nFor other resources, destination port might be derived from the referent\nresource or this field.",
+                                            "format": "int32",
+                                            "maximum": 65535,
+                                            "minimum": 1,
+                                            "type": "integer"
+                                        }
+                                    },
+                                    "required": [
+                                        "name"
+                                    ],
+                                    "type": "object",
+                                    "x-kubernetes-validations": [
+                                        {
+                                            "message": "Must have port for Service reference",
+                                            "rule": "(size(self.group) == 0 && self.kind == 'Service') ? has(self.port) : true"
+                                        }
+                                    ]
+                                }
+                            },
+                            "required": [
+                                "backendRef"
+                            ],
+                            "type": "object"
+                        },
+                        "responseHeaderModifier": {
+                            "description": "ResponseHeaderModifier defines a schema for a filter that modifies response\nheaders.\n\n\nSupport: Extended",
+                            "properties": {
+                                "add": {
+                                    "description": "Add adds the given header(s) (name, value) to the request\nbefore the action. It appends to any existing values associated\nwith the header name.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  add:\n  - name: \"my-header\"\n    value: \"bar,baz\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: foo,bar,baz",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                },
+                                "remove": {
+                                    "description": "Remove the given header(s) from the HTTP request before the action. The\nvalue of Remove is a list of HTTP header names. Note that the header\nnames are case-insensitive (see\nhttps://datatracker.ietf.org/doc/html/rfc2616#section-4.2).\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header1: foo\n  my-header2: bar\n  my-header3: baz\n\n\nConfig:\n  remove: [\"my-header1\", \"my-header3\"]\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header2: bar",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-type": "set"
+                                },
+                                "set": {
+                                    "description": "Set overwrites the request with the given header (name, value)\nbefore the action.\n\n\nInput:\n  GET /foo HTTP/1.1\n  my-header: foo\n\n\nConfig:\n  set:\n  - name: \"my-header\"\n    value: \"bar\"\n\n\nOutput:\n  GET /foo HTTP/1.1\n  my-header: bar",
+                                    "items": {
+                                        "description": "HTTPHeader represents an HTTP Header name and value as defined by RFC 7230.",
+                                        "properties": {
+                                            "name": {
+                                                "description": "Name is the name of the HTTP Header to be matched. Name matching MUST be\ncase insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).\n\n\nIf multiple entries specify equivalent header names, the first entry with\nan equivalent name MUST be considered for a match. Subsequent entries\nwith an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                                "maxLength": 256,
+                                                "minLength": 1,
+                                                "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                                "type": "string"
+                                            },
+                                            "value": {
+                                                "description": "Value is the value of HTTP Header to be matched.",
+                                                "maxLength": 4096,
+                                                "minLength": 1,
+                                                "type": "string"
+                                            }
+                                        },
+                                        "required": [
+                                            "name",
+                                            "value"
+                                        ],
+                                        "type": "object"
+                                    },
+                                    "maxItems": 16,
+                                    "type": "array",
+                                    "x-kubernetes-list-map-keys": [
+                                        "name"
+                                    ],
+                                    "x-kubernetes-list-type": "map"
+                                }
+                            },
+                            "type": "object"
+                        },
+                        "type": {
+                            "description": "Type identifies the type of filter to apply. As with other API fields,\ntypes are classified into three conformance levels:\n\n\n- Core: Filter types and their corresponding configuration defined by\n  \"Support: Core\" in this package, e.g. \"RequestHeaderModifier\". All\n  implementations supporting GRPCRoute MUST support core filters.\n\n\n- Extended: Filter types and their corresponding configuration defined by\n  \"Support: Extended\" in this package, e.g. \"RequestMirror\". Implementers\n  are encouraged to support extended filters.\n\n\n- Implementation-specific: Filters that are defined and supported by specific vendors.\n  In the future, filters showing convergence in behavior across multiple\n  implementations will be considered for inclusion in extended or core\n  conformance levels. Filter-specific configuration for such filters\n  is specified using the ExtensionRef field. `Type` MUST be set to\n  \"ExtensionRef\" for custom filters.\n\n\nImplementers are encouraged to define custom implementation types to\nextend the core API with implementation-specific behavior.\n\n\nIf a reference to a custom filter type cannot be resolved, the filter\nMUST NOT be skipped. Instead, requests that would have been processed by\nthat filter MUST receive a HTTP error response.\n\n\n",
+                            "enum": [
+                                "ResponseHeaderModifier",
+                                "RequestHeaderModifier",
+                                "RequestMirror",
+                                "ExtensionRef"
+                            ],
+                            "type": "string"
+                        }
+                    },
+                    "required": [
+                        "type"
+                    ],
+                    "type": "object",
+                    "x-kubernetes-validations": [
+                        {
+                            "message": "filter.requestHeaderModifier must be nil if the filter.type is not RequestHeaderModifier",
+                            "rule": "!(has(self.requestHeaderModifier) && self.type != 'RequestHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.requestHeaderModifier must be specified for RequestHeaderModifier filter.type",
+                            "rule": "!(!has(self.requestHeaderModifier) && self.type == 'RequestHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.responseHeaderModifier must be nil if the filter.type is not ResponseHeaderModifier",
+                            "rule": "!(has(self.responseHeaderModifier) && self.type != 'ResponseHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.responseHeaderModifier must be specified for ResponseHeaderModifier filter.type",
+                            "rule": "!(!has(self.responseHeaderModifier) && self.type == 'ResponseHeaderModifier')"
+                        },
+                        {
+                            "message": "filter.requestMirror must be nil if the filter.type is not RequestMirror",
+                            "rule": "!(has(self.requestMirror) && self.type != 'RequestMirror')"
+                        },
+                        {
+                            "message": "filter.requestMirror must be specified for RequestMirror filter.type",
+                            "rule": "!(!has(self.requestMirror) && self.type == 'RequestMirror')"
+                        },
+                        {
+                            "message": "filter.extensionRef must be nil if the filter.type is not ExtensionRef",
+                            "rule": "!(has(self.extensionRef) && self.type != 'ExtensionRef')"
+                        },
+                        {
+                            "message": "filter.extensionRef must be specified for ExtensionRef filter.type",
+                            "rule": "!(!has(self.extensionRef) && self.type == 'ExtensionRef')"
+                        }
+                    ]
                 },
-            },
-            # BackendRefs defines the backend(s) where matching requests should be sent.
-            # More info: https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1beta1.HTTPBackendRef # noqa
-            "backendRefs": {
+                "maxItems": 16,
                 "type": "array",
+                "x-kubernetes-validations": [
+                    {
+                        "message": "RequestHeaderModifier filter cannot be repeated",
+                        "rule": "self.filter(f, f.type == 'RequestHeaderModifier').size() <= 1"
+                    },
+                    {
+                        "message": "ResponseHeaderModifier filter cannot be repeated",
+                        "rule": "self.filter(f, f.type == 'ResponseHeaderModifier').size() <= 1"
+                    }
+                ]
+            },
+            "matches": {
+                "description": "Matches define conditions used for matching the rule against incoming\ngRPC requests. Each match is independent, i.e. this rule will be matched\nif **any** one of the matches is satisfied.\n\n\nFor example, take the following matches configuration:\n\n\n```\nmatches:\n- method:\n    service: foo.bar\n  headers:\n    values:\n      version: 2\n- method:\n    service: foo.bar.v2\n```\n\n\nFor a request to match against this rule, it MUST satisfy\nEITHER of the two conditions:\n\n\n- service of foo.bar AND contains the header `version: 2`\n- service of foo.bar.v2\n\n\nSee the documentation for GRPCRouteMatch on how to specify multiple\nmatch conditions to be ANDed together.\n\n\nIf no matches are specified, the implementation MUST match every gRPC request.\n\n\nProxy or Load Balancer routing configuration generated from GRPCRoutes\nMUST prioritize rules based on the following criteria, continuing on\nties. Merging MUST not be done between GRPCRoutes and HTTPRoutes.\nPrecedence MUST be given to the rule with the largest number of:\n\n\n* Characters in a matching non-wildcard hostname.\n* Characters in a matching hostname.\n* Characters in a matching service.\n* Characters in a matching method.\n* Header matches.\n\n\nIf ties still exist across multiple Routes, matching precedence MUST be\ndetermined in order of the following criteria, continuing on ties:\n\n\n* The oldest Route based on creation timestamp.\n* The Route appearing first in alphabetical order by\n  \"{namespace}/{name}\".\n\n\nIf ties still exist within the Route that has been given precedence,\nmatching precedence MUST be granted to the first matching rule meeting\nthe above criteria.",
                 "items": {
+                    "description": "GRPCRouteMatch defines the predicate used to match requests to a given\naction. Multiple match types are ANDed together, i.e. the match will\nevaluate to true only if all conditions are satisfied.\n\n\nFor example, the match below will match a gRPC request only if its service\nis `foo` AND it contains the `version: v1` header:\n\n\n```\nmatches:\n  - method:\n    type: Exact\n    service: \"foo\"\n    headers:\n  - name: \"version\"\n    value \"v1\"\n\n\n```",
                     "properties": {
-                        "filters": {
-                            "type": "array",
+                        "headers": {
+                            "description": "Headers specifies gRPC request header matchers. Multiple match values are\nANDed together, meaning, a request MUST match all the specified headers\nto select the route.",
                             "items": {
-                                "properties": FILTER_SCHEMA,
-                                "additionalProperties": False
+                                "description": "GRPCHeaderMatch describes how to select a gRPC route by matching gRPC request\nheaders.",
+                                "properties": {
+                                    "name": {
+                                        "description": "Name is the name of the gRPC Header to be matched.\n\n\nIf multiple entries specify equivalent header names, only the first\nentry with an equivalent name MUST be considered for a match. Subsequent\nentries with an equivalent header name MUST be ignored. Due to the\ncase-insensitivity of header names, \"foo\" and \"Foo\" are considered\nequivalent.",
+                                        "maxLength": 256,
+                                        "minLength": 1,
+                                        "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_\\x60|~]+$",
+                                        "type": "string"
+                                    },
+                                    "type": {
+                                        "default": "Exact",
+                                        "description": "Type specifies how to match against the value of the header.",
+                                        "enum": [
+                                            "Exact",
+                                            "RegularExpression"
+                                        ],
+                                        "type": "string"
+                                    },
+                                    "value": {
+                                        "description": "Value is the value of the gRPC Header to be matched.",
+                                        "maxLength": 4096,
+                                        "minLength": 1,
+                                        "type": "string"
+                                    }
+                                },
+                                "required": [
+                                    "name",
+                                    "value"
+                                ],
+                                "type": "object"
                             },
+                            "maxItems": 16,
+                            "type": "array",
+                            "x-kubernetes-list-map-keys": [
+                                "name"
+                            ],
+                            "x-kubernetes-list-type": "map"
                         },
-                        "group": {"type": "string"},
-                        "kind": {"type": "string"},
-                        "name": {"type": "string"},
-                        "namespace": {"type": "string"},
-                        "port": {"type": "integer"},
-                        "weight": {"type": "integer"}
+                        "method": {
+                            "description": "Method specifies a gRPC request service/method matcher. If this field is\nnot specified, all services and methods will match.",
+                            "properties": {
+                                "method": {
+                                    "description": "Value of the method to match against. If left empty or omitted, will\nmatch all services.\n\n\nAt least one of Service and Method MUST be a non-empty string.",
+                                    "maxLength": 1024,
+                                    "type": "string"
+                                },
+                                "service": {
+                                    "description": "Value of the service to match against. If left empty or omitted, will\nmatch any service.\n\n\nAt least one of Service and Method MUST be a non-empty string.",
+                                    "maxLength": 1024,
+                                    "type": "string"
+                                },
+                                "type": {
+                                    "default": "Exact",
+                                    "description": "Type specifies how to match against the service and/or method.\nSupport: Core (Exact with service and method specified)\n\n\nSupport: Implementation-specific (Exact with method specified but no service specified)\n\n\nSupport: Implementation-specific (RegularExpression)",
+                                    "enum": [
+                                        "Exact",
+                                        "RegularExpression"
+                                    ],
+                                    "type": "string"
+                                }
+                            },
+                            "type": "object",
+                            "x-kubernetes-validations": [
+                                {
+                                    "message": "One or both of 'service' or 'method' must be specified",
+                                    "rule": "has(self.type) ? has(self.service) || has(self.method) : true"
+                                },
+                                {
+                                    "message": "service must only contain valid characters (matching ^(?i)\\.?[a-z_][a-z_0-9]*(\\.[a-z_][a-z_0-9]*)*$)",
+                                    "rule": "(!has(self.type) || self.type == 'Exact') && has(self.service) ? self.service.matches(r\"\"\"^(?i)\\.?[a-z_][a-z_0-9]*(\\.[a-z_][a-z_0-9]*)*$\"\"\"): true"
+                                },
+                                {
+                                    "message": "method must only contain valid characters (matching ^[A-Za-z_][A-Za-z_0-9]*$)",
+                                    "rule": "(!has(self.type) || self.type == 'Exact') && has(self.method) ? self.method.matches(r\"\"\"^[A-Za-z_][A-Za-z_0-9]*$\"\"\"): true"
+                                }
+                            ]
+                        }
+                    },
+                    "type": "object"
+                },
+                "maxItems": 8,
+                "type": "array"
+            },
+            "sessionPersistence": {
+                "description": "SessionPersistence defines and configures session persistence\nfor the route rule.\n\n\nSupport: Extended\n\n\n",
+                "properties": {
+                    "absoluteTimeout": {
+                        "description": "AbsoluteTimeout defines the absolute timeout of the persistent\nsession. Once the AbsoluteTimeout duration has elapsed, the\nsession becomes invalid.\n\n\nSupport: Extended",
+                        "pattern": "^([0-9]{1,5}(h|m|s|ms)){1,4}$",
+                        "type": "string"
+                    },
+                    "cookieConfig": {
+                        "description": "CookieConfig provides configuration settings that are specific\nto cookie-based session persistence.\n\n\nSupport: Core",
+                        "properties": {
+                            "lifetimeType": {
+                                "default": "Session",
+                                "description": "LifetimeType specifies whether the cookie has a permanent or\nsession-based lifetime. A permanent cookie persists until its\nspecified expiry time, defined by the Expires or Max-Age cookie\nattributes, while a session cookie is deleted when the current\nsession ends.\n\n\nWhen set to \"Permanent\", AbsoluteTimeout indicates the\ncookie's lifetime via the Expires or Max-Age cookie attributes\nand is required.\n\n\nWhen set to \"Session\", AbsoluteTimeout indicates the\nabsolute lifetime of the cookie tracked by the gateway and\nis optional.\n\n\nSupport: Core for \"Session\" type\n\n\nSupport: Extended for \"Permanent\" type",
+                                "enum": [
+                                    "Permanent",
+                                    "Session"
+                                ],
+                                "type": "string"
+                            }
+                        },
+                        "type": "object"
+                    },
+                    "idleTimeout": {
+                        "description": "IdleTimeout defines the idle timeout of the persistent session.\nOnce the session has been idle for more than the specified\nIdleTimeout duration, the session becomes invalid.\n\n\nSupport: Extended",
+                        "pattern": "^([0-9]{1,5}(h|m|s|ms)){1,4}$",
+                        "type": "string"
                     },
-                    "required": ["name"],
-                    "additionalProperties": False
-                }
+                    "sessionName": {
+                        "description": "SessionName defines the name of the persistent session token\nwhich may be reflected in the cookie or the header. Users\nshould avoid reusing session names to prevent unintended\nconsequences, such as rejection or unpredictable behavior.\n\n\nSupport: Implementation-specific",
+                        "maxLength": 128,
+                        "type": "string"
+                    },
+                    "type": {
+                        "default": "Cookie",
+                        "description": "Type defines the type of session persistence such as through\nthe use a header or cookie. Defaults to cookie based session\npersistence.\n\n\nSupport: Core for \"Cookie\" type\n\n\nSupport: Extended for \"Header\" type",
+                        "enum": [
+                            "Cookie",
+                            "Header"
+                        ],
+                        "type": "string"
+                    }
+                },
+                "type": "object",
+                "x-kubernetes-validations": [
+                    {
+                        "message": "AbsoluteTimeout must be specified when cookie lifetimeType is Permanent",
+                        "rule": "!has(self.cookieConfig.lifetimeType) || self.cookieConfig.lifetimeType != 'Permanent' || has(self.absoluteTimeout)"
+                    }
+                ]
             }
         },
-        "required": ["backendRefs"],
-        "additionalProperties": False
-    }
+        "type": "object"
+    },
+    "maxItems": 16,
+    "type": "array",
 }
 
 SCHEMA = {
-    "$schema": "http://json-schema.org/draft-07/schema#",
-    "properties": {
-        "canary": RULES_SCHEMA,
-        "stable": RULES_SCHEMA,
-    },
-    "required": ["canary", "stable"],
-    "additionalProperties": False
+  "anyOf": [
+    TCP_RULES_SCHEMA,
+    UDP_RULES_SCHEMA,
+    TLS_RULES_SCHEMA,
+    HTTP_RULES_SCHEMA,
+    GRPC_RULES_SCHEMA,
+  ],
+  "$schema": "http://json-schema.org/schema#"
 }
diff --git a/rootfs/api/serializers/schemas/volumes.py b/rootfs/api/serializers/schemas/volumes.py
index 2bcb9842d..1b44509de 100644
--- a/rootfs/api/serializers/schemas/volumes.py
+++ b/rootfs/api/serializers/schemas/volumes.py
@@ -7,9 +7,18 @@
             "properties": {
                 "server": {"type": "string"},
                 "path": {"type": "string"},
-                "readOnly": {"type": "boolean"},
             },
-            "required": ["server", "path", "readOnly"],
+            "required": ["server", "path"],
+        },
+        "oss": {
+            "type": "object",
+            "properties": {
+                "bucket": {"type": "string"},
+                "server": {"type": "string"},
+                "access_key": {"type": "string"},
+                "secret_key": {"type": "string"},
+            },
+            "required": ["bucket", "server", "access_key", "secret_key"],
         },
     },
 }
diff --git a/rootfs/api/settings/celery.py b/rootfs/api/settings/celery.py
index 9fd2f3945..4a9183d94 100644
--- a/rootfs/api/settings/celery.py
+++ b/rootfs/api/settings/celery.py
@@ -5,7 +5,6 @@
 
 class Config(object):
     # Celery Configuration Options
-    timezone = "Asia/Shanghai"
     enable_utc = True
     task_serializer = 'pickle'
     accept_content = frozenset([
@@ -17,12 +16,15 @@ class Config(object):
     task_track_started = True
     task_time_limit = 30 * 60
     worker_max_tasks_per_child = 200
+    worker_prefetch_multiplier = 1
     result_expires = 24 * 60 * 60
-    broker_url = os.environ.get('DRYCC_RABBITMQ_URL', 'amqp://guest:guest@127.0.0.1:5672/')  # noqa
     cache_backend = 'django-cache'
-    task_default_queue = 'controller.low'
+    task_default_queue = 'controller.middle'
     task_default_exchange = 'controller.priority'
-    task_default_routing_key = 'controller.priority.low'
+    task_default_routing_key = 'controller.priority.middle'
+    broker_transport_options = {"queue_order_strategy": "sorted"}
+    task_create_missing_queues = True
+    task_inherit_parent_priority = True
     broker_connection_retry_on_startup = True
     worker_cancel_long_running_tasks_on_connection_loss = True
 
@@ -30,57 +32,52 @@ class Config(object):
 app = Celery('drycc')
 app.config_from_object(Config())
 app.conf.update(
+    timezone=os.environ.get('TZ', 'UTC'),
     task_routes={
+        'api.tasks.send_usage': {
+            'queue': 'controller.low',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.low',
+        },
         'api.tasks.scale_app': {
             'queue': 'controller.high',
-            'exchange': 'controller.priority',
-            'routing_key': 'controller.priority.high',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.high',
         },
         'api.tasks.mount_app': {
             'queue': 'controller.high',
-            'exchange': 'controller.priority',
-            'routing_key': 'controller.priority.high',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.high',
         },
         'api.tasks.restart_app': {
             'queue': 'controller.high',
-            'exchange': 'controller.priority',
-            'routing_key': 'controller.priority.high',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.high',
         },
         'api.tasks.run_pipeline': {
             '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',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.high',
         },
-        'api.tasks.send_measurements': {
+        'api.tasks.send_app_log': {
             'queue': 'controller.middle',
-            'exchange': 'controller.priority',
-            'routing_key': 'controller.priority.middle',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.middle',
         },
     },
     task_queues=(
         Queue(
-            'controller.low',
-            exchange=Exchange('controller.priority', type="direct"),
+            'controller.low', exchange=Exchange('controller.priority', type="direct"),
             routing_key='controller.priority.low',
-            queue_arguments={'x-max-priority': 16},
         ),
         Queue(
-            'controller.high',
-            exchange=Exchange('controller.priority', type="direct"),
+            'controller.high', exchange=Exchange('controller.priority', type="direct"),
             routing_key='controller.priority.high',
-            queue_arguments={'x-max-priority': 64},
         ),
         Queue(
-            'controller.middle',
-            exchange=Exchange('controller.priority', type="direct"),
+            'controller.middle', exchange=Exchange('controller.priority', type="direct"),
             routing_key='controller.priority.middle',
-            queue_arguments={'x-max-priority': 32},
         ),
     ),
 )
+DRYCC_VALKEY_URL = os.environ.get('DRYCC_VALKEY_URL', 'redis://:@127.0.0.1:6379')
+app.conf.update(
+    broker_url=DRYCC_VALKEY_URL,
+    result_backend=DRYCC_VALKEY_URL,
+    broker_transport_options={"queue_order_strategy": "sorted", "visibility_timeout": 43200},
+)
 app.autodiscover_tasks()
diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py
index 091e1aab0..7f2d43b88 100644
--- a/rootfs/api/settings/production.py
+++ b/rootfs/api/settings/production.py
@@ -4,11 +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'))
 
@@ -67,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': {
@@ -92,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'
@@ -108,7 +114,6 @@
     'django.contrib.sessions',
     # Third-party apps
     'corsheaders',
-    'guardian',
     'gunicorn',
     'rest_framework',
     'social_django',
@@ -121,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
@@ -173,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
@@ -244,35 +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(',')
-DRYCC_RESERVED_NAMES.append("canary")
+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,47 +290,64 @@
     with open(DRYCC_METRICS_CONFIG_PATH) as fd:
         DRYCC_METRICS_CONFIG = json.load(fd)
 DRYCC_METRICS_EXPIRY = int(os.environ.get('DRYCC_METRICS_EXPIRY', '20'))
-
-# 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_METRICS_INTERVAL = os.environ.get('DRYCC_METRICS_INTERVAL', '5m')
+
+# 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 = fd.read()
+
+# drycc volume claim template
+DRYCC_VOLUME_CLAIM_TEMPLATE = {}
+DRYCC_VOLUME_CLAIM_TEMPLATE_PATH = os.environ.get(
+    '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 = 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")
 
-# True, true, yes, y and more evaluate to True
-# False, false, no, n and more evaluate to False
-# see the above for all available options
-#
-# If a user deploys one build with a Procfile but then forgets to in the next one
-# then let that go through without scaling the missing process types down
-#
-# If the user has a Procfile in both deploys then processes are scaled up / down as per usual
-#
-# By default the process types are scaled down unless this setting is turned on
-DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE = os.environ.get(
-    'DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE', 'true').lower() == "true"
-
-# True, true, yes, y and more evaluate to True
-# False, false, no, n and more evaluate to False
-# see the above for all available options
-#
-# If a previous deploy had a Procfile but then the following deploy has no Procfile then it will
-# result in a 406 - Not Acceptable
-# Has priority over DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE
-DRYCC_DEPLOY_REJECT_IF_PROCFILE_MISSING = os.environ.get(
-    'DRYCC_DEPLOY_REJECT_IF_PROCFILE_MISSING', 'false').lower() == "true"
+# apply task size
+DRYCC_APPLY_TASKS = int(os.environ.get('DRYCC_APPLY_TASKS', '20'))
+
+# Drycc filer image
+# Provide get and put operations for `drycc volumes:client`
+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'))
 
 # Define a global default on how many pods to bring up and then
 # take down sequentially during a deploy
@@ -347,16 +372,14 @@
 
 DRYCC_DEPLOY_HOOK_SECRET_KEY = os.environ.get('DRYCC_DEPLOY_HOOK_SECRET_KEY', None)
 
-DRYCC_APP_STORAGE_CLASS = os.environ.get('DRYCC_APP_STORAGE_CLASS', "")
+DRYCC_APP_GATEWAY_CLASS = os.environ.get('DRYCC_APP_GATEWAY_CLASS', "")
 
-DRYCC_APP_RUNTIME_CLASS = os.environ.get('DRYCC_APP_RUNTIME_CLASS', "")
+DRYCC_APP_STORAGE_CLASS = os.environ.get('DRYCC_APP_STORAGE_CLASS', "")
 
 DRYCC_APP_DNS_POLICY = os.environ.get('DRYCC_APP_DNS_POLICY', "")
 
 DRYCC_APP_POD_EXEC_TIMEOUT = int(os.environ.get('DRYCC_APP_POD_EXEC_TIMEOUT', "3600"))
 
-DRYCC_DEFAULT_CONFIG_TAGS = os.environ.get('DRYCC_DEFAULT_CONFIG_TAGS', '')
-
 KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT = os.environ.get(
     'KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT', None)
 
@@ -376,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)
@@ -390,60 +409,43 @@
 if DRYCC_DATABASE_REPLICA_URL is not None:
     DATABASES["replica"] = dj_database_url.config(default=DRYCC_DATABASE_REPLICA_URL)
 
-# database monitor setting
-DRYCC_DATABASE_MONITOR_URL = os.environ.get('DRYCC_DATABASE_MONITOR_URL', None)
-if DRYCC_DATABASE_MONITOR_URL is not None:
-    DATABASES["monitor"] = dj_database_url.config(default=DRYCC_DATABASE_MONITOR_URL)
-
 # database routers
-DATABASE_ROUTERS = ['api.routers.DefaultReplicaRouter', ]
+# Implements: 'api.routers.DefaultReplicaRouter'
+DATABASE_ROUTERS = [r for r in os.environ.get('DRYCC_DATABASE_ROUTERS', '').split(',') if r]
+
 
 APP_URL_REGEX = '[a-z0-9-]+'
 
+DOMAIN_URL_REGEX = r'\**\.?[-\._\w]+'
+
+NAME_REGEX = r'[a-z0-9]+(\-[a-z0-9]+)*'
+
 # Oauth settings
 
 DRYCC_PASSPORT_URL = os.environ.get('DRYCC_PASSPORT_URL', 'https://127.0.0.1:8000')
+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',
@@ -451,32 +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
-OAUTH_CACHE_USER_TIME = int(os.environ.get('OAUTH_CACHE_USER_TIME', 30 * 60))
+DRYCC_CACHE_USER_TIME = int(os.environ.get('DRYCC_CACHE_USER_TIME', 30 * 60))
 
-# Redis Configuration
-DRYCC_REDIS_ADDRS = os.environ.get('DRYCC_REDIS_ADDRS', '127.0.0.1:6379').split(",")
-DRYCC_REDIS_PASSWORD = os.environ.get('DRYCC_REDIS_PASSWORD', '')
+# 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 Configuration
+# Cache Valkey Configuration
 CACHES = {
     "default": {
-        "BACKEND": "django_redis.cache.RedisCache",
-        "LOCATION": [
-            'redis://:{}@{}'.format(
-                DRYCC_REDIS_PASSWORD, DRYCC_REDIS_ADDR) for DRYCC_REDIS_ADDR in DRYCC_REDIS_ADDRS
-        ],
-        "OPTIONS": {
-            "CLIENT_CLASS": "django_redis.client.ShardClient",
-        }
+        "BACKEND": "django.core.cache.backends.redis.RedisCache",
+        "LOCATION": os.environ.get('DRYCC_VALKEY_URL', 'redis://:@127.0.0.1:6379'),
     }
 }
 
+# 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 41b5bdb57..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: []
@@ -43,8 +52,6 @@
 KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS = int(
     os.environ.get('KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS', 2))
 
-DRYCC_DEFAULT_CONFIG_TAGS = os.environ.get('DRYCC_DEFAULT_CONFIG_TAGS', '')
-
 DRYCC_APP_STORAGE_CLASS = os.environ.get('DRYCC_APP_STORAGE_CLASS', '')
 
 
@@ -62,3 +69,67 @@ def __getitem__(self, item):
 # WORKFLOW_MANAGER_URL = "http://127.0.0.1:8000"
 WORKFLOW_MANAGER_ACCESS_KEY = "1234567890"
 WORKFLOW_MANAGER_SECRET_KEY = "1234567890"
+
+DRYCC_VOLUME_CLAIM_TEMPLATE = """
+{% 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 == '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 6ac1a9be9..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()
@@ -90,10 +92,10 @@ def _hook_release_created(**kwargs):
             url = deploy_hook
             params = {
                 'app': release.app,
-                'release': 'v{}'.format(release.version),
+                '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):
@@ -182,7 +192,11 @@ def gateway_changed_handle(
 def service_changed_handle(
         sender, instance: Service, created=False, update_fields=None, **kwargs):
     if kwargs['signal'] == post_delete:
-        instance.app.route_set.filter(procfile_type=instance.procfile_type).delete()
+        for route in instance.app.route_set.all():
+            if route.cleaned_rules:
+                route.save()
+            else:
+                route.delete()
 
 
 @receiver(signal=[post_save, post_delete], sender=Domain)
@@ -203,25 +217,12 @@ def domain_changed_handle(
 @receiver(signal=[post_save, post_delete], sender=AppSettings)
 def appsettings_changed_handle(
         sender, instance: AppSettings, created=False, update_fields=None, **kwargs):
-    prev_settings, action, canaries = instance.diff_canaries()
-    if prev_settings is not None:
-        release = instance.app.release_set.filter(failed=False).latest()
-        if release.canary:
-            if action == "append":
-                instance.app.deploy(release)
-            elif action == "remove":
-                instance.app.cleanup_old()
-        for procfile_type in canaries:
-            canary = (action == "append")
-            service = instance.app.service_set.filter(procfile_type=procfile_type).first()
-            if service is not None and service.canary != canary:
-                service.canary = canary
-                service.save()
-        if prev_settings.routable != instance.routable:
-            for route in instance.app.route_set.all():
-                if route.routable != instance.routable:
-                    route.routable = instance.routable
-                    route.save()
+    prev_settings = instance.previous()
+    if prev_settings and prev_settings.routable != instance.routable:
+        for route in instance.app.route_set.all():
+            if route.routable != instance.routable:
+                route.routable = instance.routable
+                route.save()
 
 
 @receiver(post_save, sender=Config)
@@ -230,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), ],
         )
 
 
@@ -240,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), ],
         )
 
 
@@ -255,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 1d198923b..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
@@ -79,6 +91,23 @@ def restart_app(app, **kwargs):
         signals.request_finished.send(sender=task_id)
 
 
+@shared_task(
+    autoretry_for=(ServiceUnavailable, ),
+    retry_jitter=True,
+    retry_kwargs={'max_retries': 3}
+)
+def delete_pod(app, **kwargs):
+    task_id = uuid.uuid4().hex
+    signals.request_started.send(sender=task_id)
+    try:
+        app.delete_pod(**kwargs)
+    except Exception as e:
+        signals.got_request_exception.send(sender=task_id)
+        raise e
+    else:
+        signals.request_finished.send(sender=task_id)
+
+
 @shared_task(
     autoretry_for=(ServiceUnavailable, ),
     retry_jitter=True,
@@ -110,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 c185fdd23..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)
@@ -81,16 +124,16 @@ def get_or_create_token(self, user):
         )
         return token.key
 
-    def assertPodContains(self, pods, app_id, procfile_type, version, state="up"):
+    def assertPodContains(self, pods, app_id, ptype, version, state="up"):
         for pod in pods:
-            if (pod["type"] == procfile_type and
+            if (pod["type"] == ptype and
                     pod["release"] == version and pod["state"] == state):
-                pod_name = app_id + '-%s-[0-9]{1,10}-[a-z0-9]{5}' % procfile_type
+                pod_name = app_id + '-%s-[0-9]{1,10}-[a-z0-9]{5}' % ptype
                 self.assertRegex(pod['name'], pod_name)
                 return
         raise ValueError(
-            "pod not contains: procfile_type={}, version={}, state={}".format(
-                procfile_type, version, state
+            "pod not contains: ptype={}, version={}, state={}".format(
+                ptype, version, state
             )
         )
 
diff --git a/rootfs/api/tests/admissions/service_binding_binding_request_in_flight.json b/rootfs/api/tests/admissions/service_binding_binding_request_in_flight.json
index 85b4024a4..5dc9641cc 100644
--- a/rootfs/api/tests/admissions/service_binding_binding_request_in_flight.json
+++ b/rootfs/api/tests/admissions/service_binding_binding_request_in_flight.json
@@ -25,7 +25,7 @@
 			"resource": "servicebindings"
 		},
 		"requestSubResource": "status",
-		"name": "redis-t2",
+		"name": "valkey-t2",
 		"namespace": "myapp",
 		"operation": "UPDATE",
 		"userInfo": {
@@ -116,7 +116,7 @@
 						"time": "2024-03-25T01:48:21Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74043277",
 				"uid": "292b0ef5-947f-42a8-adfb-909e99d26906"
@@ -124,9 +124,9 @@
 			"spec": {
 				"externalID": "57414c7d-0931-4310-8ed6-d8368ac0d3de",
 				"instanceRef": {
-					"name": "redis-t2"
+					"name": "valkey-t2"
 				},
-				"secretName": "redis-t2",
+				"secretName": "valkey-t2",
 				"userInfo": {
 					"extra": {
 						"authentication.kubernetes.io/pod-name": [
@@ -238,7 +238,7 @@
 						"time": "2024-03-25T01:48:21Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74043277",
 				"uid": "292b0ef5-947f-42a8-adfb-909e99d26906"
@@ -246,9 +246,9 @@
 			"spec": {
 				"externalID": "57414c7d-0931-4310-8ed6-d8368ac0d3de",
 				"instanceRef": {
-					"name": "redis-t2"
+					"name": "valkey-t2"
 				},
-				"secretName": "redis-t2",
+				"secretName": "valkey-t2",
 				"userInfo": {
 					"extra": {
 						"authentication.kubernetes.io/pod-name": [
diff --git a/rootfs/api/tests/admissions/service_binding_ready.json b/rootfs/api/tests/admissions/service_binding_ready.json
index 9230ea0ff..d486b302b 100644
--- a/rootfs/api/tests/admissions/service_binding_ready.json
+++ b/rootfs/api/tests/admissions/service_binding_ready.json
@@ -25,7 +25,7 @@
 			"resource": "servicebindings"
 		},
 		"requestSubResource": "status",
-		"name": "redis-t2",
+		"name": "valkey-t2",
 		"namespace": "myapp",
 		"operation": "UPDATE",
 		"userInfo": {
@@ -114,7 +114,7 @@
 						"time": "2024-03-25T01:48:22Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74043278",
 				"uid": "292b0ef5-947f-42a8-adfb-909e99d26906"
@@ -122,9 +122,9 @@
 			"spec": {
 				"externalID": "57414c7d-0931-4310-8ed6-d8368ac0d3de",
 				"instanceRef": {
-					"name": "redis-t2"
+					"name": "valkey-t2"
 				},
-				"secretName": "redis-t2",
+				"secretName": "valkey-t2",
 				"userInfo": {
 					"extra": {
 						"authentication.kubernetes.io/pod-name": [
@@ -250,7 +250,7 @@
 						"time": "2024-03-25T01:48:21Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74043278",
 				"uid": "292b0ef5-947f-42a8-adfb-909e99d26906"
@@ -258,9 +258,9 @@
 			"spec": {
 				"externalID": "57414c7d-0931-4310-8ed6-d8368ac0d3de",
 				"instanceRef": {
-					"name": "redis-t2"
+					"name": "valkey-t2"
 				},
-				"secretName": "redis-t2",
+				"secretName": "valkey-t2",
 				"userInfo": {
 					"extra": {
 						"authentication.kubernetes.io/pod-name": [
diff --git a/rootfs/api/tests/admissions/service_instance_provisioning.json b/rootfs/api/tests/admissions/service_instance_provisioning.json
index 444359a58..fd4f68f17 100644
--- a/rootfs/api/tests/admissions/service_instance_provisioning.json
+++ b/rootfs/api/tests/admissions/service_instance_provisioning.json
@@ -25,7 +25,7 @@
 			"resource": "serviceinstances"
 		},
 		"requestSubResource": "status",
-		"name": "redis-t2",
+		"name": "valkey-t2",
 		"namespace": "myapp",
 		"operation": "UPDATE",
 		"userInfo": {
@@ -141,13 +141,13 @@
 						"time": "2024-03-25T01:42:31Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74040375",
 				"uid": "0c9c3c73-d1b7-4183-9eb5-77ce93f8f8d8"
 			},
 			"spec": {
-				"clusterServiceClassExternalName": "redis",
+				"clusterServiceClassExternalName": "valkey",
 				"clusterServiceClassRef": {
 					"name": "e1fd0d37-9046-4152-a29b-d155c5657c8b"
 				},
@@ -215,7 +215,7 @@
 				"orphanMitigationInProgress": false,
 				"provisionStatus": "",
 				"reconciledGeneration": 0,
-				"userSpecifiedClassName": "ClusterServiceClass/redis",
+				"userSpecifiedClassName": "ClusterServiceClass/valkey",
 				"userSpecifiedPlanName": "standard-128"
 			}
 		},
@@ -315,13 +315,13 @@
 						"time": "2024-03-25T01:42:31Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74040375",
 				"uid": "0c9c3c73-d1b7-4183-9eb5-77ce93f8f8d8"
 			},
 			"spec": {
-				"clusterServiceClassExternalName": "redis",
+				"clusterServiceClassExternalName": "valkey",
 				"clusterServiceClassRef": {
 					"name": "e1fd0d37-9046-4152-a29b-d155c5657c8b"
 				},
@@ -389,7 +389,7 @@
 				"orphanMitigationInProgress": false,
 				"provisionStatus": "",
 				"reconciledGeneration": 0,
-				"userSpecifiedClassName": "ClusterServiceClass/redis",
+				"userSpecifiedClassName": "ClusterServiceClass/valkey",
 				"userSpecifiedPlanName": "standard-128"
 			}
 		},
diff --git a/rootfs/api/tests/admissions/service_instance_ready.json b/rootfs/api/tests/admissions/service_instance_ready.json
index 07a2b86e3..60f833f3a 100644
--- a/rootfs/api/tests/admissions/service_instance_ready.json
+++ b/rootfs/api/tests/admissions/service_instance_ready.json
@@ -25,7 +25,7 @@
 			"resource": "serviceinstances"
 		},
 		"requestSubResource": "status",
-		"name": "redis-t2",
+		"name": "valkey-t2",
 		"namespace": "myapp",
 		"operation": "UPDATE",
 		"userInfo": {
@@ -139,13 +139,13 @@
 						"time": "2024-03-25T01:44:38Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74040375",
 				"uid": "0c9c3c73-d1b7-4183-9eb5-77ce93f8f8d8"
 			},
 			"spec": {
-				"clusterServiceClassExternalName": "redis",
+				"clusterServiceClassExternalName": "valkey",
 				"clusterServiceClassRef": {
 					"name": "e1fd0d37-9046-4152-a29b-d155c5657c8b"
 				},
@@ -211,7 +211,7 @@
 				"orphanMitigationInProgress": false,
 				"provisionStatus": "Provisioned",
 				"reconciledGeneration": 2,
-				"userSpecifiedClassName": "ClusterServiceClass/redis",
+				"userSpecifiedClassName": "ClusterServiceClass/valkey",
 				"userSpecifiedPlanName": "standard-128"
 			}
 		},
@@ -311,13 +311,13 @@
 						"time": "2024-03-25T01:42:31Z"
 					}
 				],
-				"name": "redis-t2",
+				"name": "valkey-t2",
 				"namespace": "myapp",
 				"resourceVersion": "74040375",
 				"uid": "0c9c3c73-d1b7-4183-9eb5-77ce93f8f8d8"
 			},
 			"spec": {
-				"clusterServiceClassExternalName": "redis",
+				"clusterServiceClassExternalName": "valkey",
 				"clusterServiceClassRef": {
 					"name": "e1fd0d37-9046-4152-a29b-d155c5657c8b"
 				},
@@ -385,7 +385,7 @@
 				"orphanMitigationInProgress": false,
 				"provisionStatus": "",
 				"reconciledGeneration": 0,
-				"userSpecifiedClassName": "ClusterServiceClass/redis",
+				"userSpecifiedClassName": "ClusterServiceClass/valkey",
 				"userSpecifiedPlanName": "standard-128"
 			}
 		},
diff --git a/rootfs/api/tests/rules/grpc_rules.json b/rootfs/api/tests/rules/grpc_rules.json
index 925787558..6a882a0a3 100644
--- a/rootfs/api/tests/rules/grpc_rules.json
+++ b/rootfs/api/tests/rules/grpc_rules.json
@@ -1,56 +1,33 @@
-{
-	"stable": [{
-			"matches": [{
+[
+	{
+		"matches": [
+			{
 				"method": {
 					"service": "com.example.User",
 					"method": "Login"
 				}
-			}],
-			"backendRefs": [{
+			}
+		],
+		"backendRefs": [
+			{
 				"name": "foo-svc",
 				"port": 50051
-			}]
-		},
-		{
-			"matches": [{
+			}
+		]
+	},
+	{
+		"matches": [
+			{
 				"method": {
 					"service": "grpc.reflection.v1.ServerReflection"
 				}
-			}],
-			"backendRefs": [{
+			}
+		],
+		"backendRefs": [
+			{
 				"name": "foo-svc",
 				"port": 50051
-			}]
-		}
-	],
-	"canary": [{
-			"matches": [{
-				"method": {
-					"service": "com.example.User",
-					"method": "Login"
-				}
-			}],
-			"backendRefs": [{
-				"name": "foo-svc",
-				"port": 50051
-			}, {
-				"name": "foo-svc-canary",
-				"port": 50051
-			}]
-		},
-		{
-			"matches": [{
-				"method": {
-					"service": "grpc.reflection.v1.ServerReflection"
-				}
-			}],
-			"backendRefs": [{
-				"name": "foo-svc",
-				"port": 50051
-			}, {
-				"name": "foo-svc-canary",
-				"port": 50051
-			}]
-		}
-	]
-}
\ No newline at end of file
+			}
+		]
+	}
+]
\ No newline at end of file
diff --git a/rootfs/api/tests/rules/http_rules.json b/rootfs/api/tests/rules/http_rules.json
index 4021ea883..a7cf7b6b0 100644
--- a/rootfs/api/tests/rules/http_rules.json
+++ b/rootfs/api/tests/rules/http_rules.json
@@ -1,69 +1,43 @@
-{
-	"stable": [{
-		"matches": [{
-			"path": {
-				"type": "PathPrefix",
-				"value": "/add-multiple-response-headers"
-			}
-		}],
-		"filters": [{
-			"type": "ResponseHeaderModifier",
-			"responseHeaderModifier": {
-				"add": [{
-						"name": "X-Header-Add-1",
-						"value": "header-add-1"
-					},
-					{
-						"name": "X-Header-Add-2",
-						"value": "header-add-2"
-					},
-					{
-						"name": "X-Header-Add-3",
-						"value": "header-add-3"
-					}
-				]
-			}
-		}],
-		"backendRefs": [{
-			"name": "echo",
-			"port": 8080
-		}]
-	}],
-	"canary": [{
-		"matches": [{
-			"path": {
-				"type": "PathPrefix",
-				"value": "/add-multiple-response-headers"
+[
+	{
+		"matches": [
+			{
+				"path": {
+					"type": "PathPrefix",
+					"value": "/add-multiple-response-headers"
+				}
 			}
-		}],
-		"filters": [{
-			"type": "ResponseHeaderModifier",
-			"responseHeaderModifier": {
-				"add": [{
-						"name": "X-Header-Add-1",
-						"value": "header-add-1"
-					},
-					{
-						"name": "X-Header-Add-2",
-						"value": "header-add-2"
-					},
-					{
-						"name": "X-Header-Add-3",
-						"value": "header-add-3"
-					}
-				]
+		],
+		"filters": [
+			{
+				"type": "ResponseHeaderModifier",
+				"responseHeaderModifier": {
+					"add": [
+						{
+							"name": "X-Header-Add-1",
+							"value": "header-add-1"
+						},
+						{
+							"name": "X-Header-Add-2",
+							"value": "header-add-2"
+						},
+						{
+							"name": "X-Header-Add-3",
+							"value": "header-add-3"
+						}
+					]
+				}
 			}
-		}],
-		"backendRefs": [{
-				"name": "echo",
-				"port": 8080,
-				"weight": 10
-			},
+		],
+		"backendRefs": [
 			{
-				"name": "echo-canary",
-				"port": 8080,
-				"weight": 90
+				"name": "echo",
+				"port": 8080
 			}
-		]
-	}]
-}
\ No newline at end of file
+		],
+		"timeouts": {
+			"backendRequest": "60s",
+			"request": "60s"
+		}
+	}
+]
\ No newline at end of file
diff --git a/rootfs/api/tests/rules/tcp_rules.json b/rootfs/api/tests/rules/tcp_rules.json
index d13cab8cc..0ca5ea93f 100644
--- a/rootfs/api/tests/rules/tcp_rules.json
+++ b/rootfs/api/tests/rules/tcp_rules.json
@@ -1,21 +1,11 @@
-{
-	"stable": [{
-		"backendRefs": [{
-			"name": "echo",
-			"port": 8080
-		}]
-	}],
-	"canary": [{
-		"backendRefs": [{
+[
+	{
+		"backendRefs": [
+			{
 				"name": "echo",
 				"port": 8080,
 				"weight": 10
-			},
-			{
-				"name": "echo-canary",
-				"port": 8080,
-				"weight": 90
 			}
 		]
-	}]
-}
\ No newline at end of file
+	}
+]
\ No newline at end of file
diff --git a/rootfs/api/tests/test_admissions.py b/rootfs/api/tests/test_admissions.py
index bcd4cbd90..00b9463ec 100644
--- a/rootfs/api/tests/test_admissions.py
+++ b/rootfs/api/tests/test_admissions.py
@@ -53,21 +53,21 @@ def tearDown(self):
 
     def test_admissions_scale(self, requests_mock):
         app_id = self.create_app("myapp")
-        for case, (admission_class, procfile_type, scale) in SCALE_TEST_CASES:
+        for case, (admission_class, ptype, scale) in SCALE_TEST_CASES:
             with open(os.path.join(TEST_ROOT, "admissions", case)) as f:
                 request = json.loads(f.read())["request"]
                 handler = admission_class()
                 self.assertEqual(handler.detect(request), True)
                 self.assertEqual(handler.handle(request), True)
                 app = App.objects.get(id=app_id)
-                self.assertEqual(app.structure.get(procfile_type, 0), scale)
+                self.assertEqual(app.structure.get(ptype, 0), scale)
 
     def test_admissions_service_catalog(self, requests_mock):
         app_id = self.create_app("myapp")
-        resource_name = 'redis-t2'
+        resource_name = 'valkey-t2'
         response = self.client.post(
             '/v2/apps/{}/resources'.format(app_id),
-            data={'name': resource_name, 'plan': 'redis:standard-128'}
+            data={'name': resource_name, 'plan': 'valkey:standard-128'}
         )
         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 e2a1a3dfb..162c8ae19 100644
--- a/rootfs/api/tests/test_app.py
+++ b/rootfs/api/tests/test_app.py
@@ -15,8 +15,9 @@
 from django.core.cache import cache
 from django.test.utils import override_settings
 
-from api.models.app import App, PROCFILE_TYPE_WEB
-from api.models.config import Config
+from api.models.app import App
+from api.models.base import PTYPE_WEB
+from api.models.workspace import Workspace, WorkspaceMember
 from scheduler import KubeException, KubeHTTPException
 
 from api.exceptions import DryccException
@@ -44,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
@@ -65,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)
@@ -89,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)
@@ -157,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(
@@ -193,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)
@@ -215,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)
@@ -233,8 +261,8 @@ def test_run_without_release_should_error(self, mock_requests):
         body = {'command': 'ls -al'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
-        self.assertEqual(response.data, {'detail': 'No build associated with this '
-                                                   'release to run this command'})
+        self.assertEqual(
+            str(response.data["detail"]), 'no build available, please deploy a release')
 
     @mock.patch('api.models.app.App.run', _mock_run)
     @mock.patch('api.models.app.App.deploy', mock_none)
@@ -246,7 +274,7 @@ def test_run(self, mock_requests):
 
         # create build
         body = {'image': 'autotest/example', 'stack': 'container'}
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
@@ -270,7 +298,7 @@ def test_run_failure(self, mock_requests):
 
         # create build
         body = {'image': 'autotest/example', 'stack': 'container'}
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
@@ -287,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')
@@ -297,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()
@@ -317,64 +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)
+        # 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)
 
-        # 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.
-        config1 = Config.objects.create(owner=owner, app=app, values={'FOO': 'bar'})
-        config2 = Config.objects.create(owner=collaborator, app=app, values={'CAR': 'star'})
+        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()
-
-        # 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)
-
-        # Collaborators can't transfer
-        body = {'username': owner.username}
-        perms_url = url+"/perms/"
-        response = self.client.post(perms_url, body)
+
+        # 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)
 
-        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)
+        response = self.client.patch(
+            f'/v2/apps/{app_id}',
+            {'workspace': 'wstarget'},
+        )
+        self.assertEqual(response.status_code, 204, response.data)
 
-        # 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)
+        app = App.objects.get(id=app_id)
+        self.assertEqual(app.workspace.name, 'wstarget')
+
+    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')
+
+    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):
         """
@@ -445,12 +487,11 @@ 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
-        url = "/v2/apps/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -481,11 +522,10 @@ 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/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -505,12 +545,11 @@ 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
-        url = "/v2/apps/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -524,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)
 
@@ -533,27 +572,27 @@ 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 = {'username': 'test', 'password': 'test'}
+        registry = {"web": {'username': 'test', 'password': 'test'}}
         auth = bytes('{}:{}'.format("test", "test"), 'UTF-8')
         encAuth = base64.b64encode(auth).decode(encoding='UTF-8')
         image = 'test/test'
 
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry.get("web", {}))  # noqa
         dockerConfig = json.loads(docker_config)
         expected = {"https://index.docker.io/v1/": {"auth": encAuth}}
         self.assertEqual(dockerConfig.get('auths'), expected)
-        self.assertEqual(name, "private-registry")
+        self.assertEqual(name, "private-registry-web")
         self.assertEqual(create, True)
 
         image = "quay.io/test/test"
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry.get("web", {}))  # noqa
         dockerConfig = json.loads(docker_config)
         expected = {"quay.io": {"auth": encAuth}}
         self.assertEqual(dockerConfig.get('auths'), expected)
-        self.assertEqual(name, "private-registry")
+        self.assertEqual(name, "private-registry-web")
         self.assertEqual(create, True)
 
     @override_settings(REGISTRY_LOCATION="off-cluster")
@@ -562,36 +601,36 @@ def test_get_private_registry_config_off_cluster(self, mock_requests):
         auth = bytes('{}:{}'.format("test", "test"), 'UTF-8')
         encAuth = base64.b64encode(auth).decode(encoding='UTF-8')
         image = "test.com/test/test"
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry.get("web", {}))  # noqa
         dockerConfig = json.loads(docker_config)
         expected = {"https://index.docker.io/v1/": {
             "auth": encAuth
         }}
         self.assertEqual(dockerConfig.get('auths'), expected)
-        self.assertEqual(name, "private-registry-off-cluster")
+        self.assertEqual(name, "private-registry-web-off-cluster")
         self.assertEqual(create, True)
 
     @override_settings(REGISTRY_LOCATION="ecra")
     def test_get_private_registry_config_bad_registry_location(self, mock_requests):
         registry = {}
         image = "test.com/test/test"
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry)
         self.assertEqual(docker_config, None)
         self.assertEqual(name, None)
         self.assertEqual(create, None)
 
     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(), PROCFILE_TYPE_WEB)
+            app._build_env_vars(app.release_set.latest(), PTYPE_WEB)
         data = {'image': 'autotest/example', 'stack': 'heroku-18'}
-        url = f"/v2/apps/{app.id}/builds"
+        url = f"/v2/apps/{app.id}/build"
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, 201, response.data)
         time_created = app.release_set.latest().created
         self.assertEqual(
-            app._build_env_vars(app.release_set.latest(), PROCFILE_TYPE_WEB),
+            app._build_env_vars(app.release_set.latest(), PTYPE_WEB),
             {
                 'DRYCC_APP': app.id,
                 'WORKFLOW_RELEASE': 'v2',
@@ -606,7 +645,7 @@ def test_build_env_vars(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
         time_created = app.release_set.latest().created
         self.assertEqual(
-            app._build_env_vars(app.release_set.latest(), PROCFILE_TYPE_WEB),
+            app._build_env_vars(app.release_set.latest(), PTYPE_WEB),
             {
                 'DRYCC_APP': app.id,
                 'WORKFLOW_RELEASE': 'v3',
@@ -618,27 +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}/builds"
+        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={
-                'DRYCC_DEPLOY_TIMEOUT': '60',
-                'DRYCC_DEPLOY_BATCHES': '3',
-                'KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS': '60'
-            })
+        # 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": "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):
         """
@@ -656,7 +702,7 @@ def test_app_name_bad_regex(self, mock_requests):
         self.assertEqual(response.data['results'][0]['id'], app_id, response.data)
 
         # deploy to an app that doesn't exist should fail with 404
-        url = "/v2/apps/{}/builds".format('={}'.format(app_id))
+        url = "/v2/apps/{}/build".format('={}'.format(app_id))
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 404, response)
diff --git a/rootfs/api/tests/test_app_settings.py b/rootfs/api/tests/test_app_settings.py
index 08980dae2..794d77004 100644
--- a/rootfs/api/tests/test_app_settings.py
+++ b/rootfs/api/tests/test_app_settings.py
@@ -24,7 +24,7 @@ def tearDown(self):
         # make sure every test has a clean slate for k8s mocking
         cache.clear()
 
-    def test_settings_routable(self, mock_requests):
+    def test_settings_bool(self, mock_requests):
         """
         Create an application with the routable flag turned on or off
         """
@@ -32,6 +32,8 @@ def test_settings_routable(self, mock_requests):
         app_id = self.create_app()
         app = App.objects.get(id=app_id)
         self.assertTrue(app.appsettings_set.latest().routable)
+        self.assertTrue(app.appsettings_set.latest().autodeploy)
+        self.assertTrue(app.appsettings_set.latest().autorollback)
         # Set routable to false
         response = self.client.post(
             f'/v2/apps/{app.id}/settings',
@@ -39,6 +41,58 @@ def test_settings_routable(self, mock_requests):
         )
         self.assertEqual(response.status_code, 201, response.data)
         self.assertFalse(app.appsettings_set.latest().routable)
+        self.assertTrue(app.appsettings_set.latest().autodeploy)
+        self.assertTrue(app.appsettings_set.latest().autorollback)
+
+        # Set autodeploy to false
+        response = self.client.post(
+            f'/v2/apps/{app.id}/settings',
+            {'autodeploy': False}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertFalse(app.appsettings_set.latest().routable)
+        self.assertFalse(app.appsettings_set.latest().autodeploy)
+        self.assertTrue(app.appsettings_set.latest().autorollback)
+
+        # Set autorollback to false
+        response = self.client.post(
+            f'/v2/apps/{app.id}/settings',
+            {'autorollback': False}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertFalse(app.appsettings_set.latest().routable)
+        self.assertFalse(app.appsettings_set.latest().autodeploy)
+        self.assertFalse(app.appsettings_set.latest().autorollback)
+
+        # Set autorollback to true
+        response = self.client.post(
+            f'/v2/apps/{app.id}/settings',
+            {'autorollback': True}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertFalse(app.appsettings_set.latest().routable)
+        self.assertFalse(app.appsettings_set.latest().autodeploy)
+        self.assertTrue(app.appsettings_set.latest().autorollback)
+
+        # Set autodeploy to true
+        response = self.client.post(
+            f'/v2/apps/{app.id}/settings',
+            {'autodeploy': True}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertFalse(app.appsettings_set.latest().routable)
+        self.assertTrue(app.appsettings_set.latest().autodeploy)
+        self.assertTrue(app.appsettings_set.latest().autorollback)
+
+        # Set routable to false
+        response = self.client.post(
+            f'/v2/apps/{app.id}/settings',
+            {'routable': True}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertTrue(app.appsettings_set.latest().routable)
+        self.assertTrue(app.appsettings_set.latest().autodeploy)
+        self.assertTrue(app.appsettings_set.latest().autorollback)
 
     def test_autoscale(self, mock_requests):
         """
@@ -82,8 +136,12 @@ def test_autoscale(self, mock_requests):
         # check that config fails if trying to unset non-existing proc type
         response = self.client.post(
             f'/v2/apps/{app_id}/settings',
-            {'autoscale': {'invalid_proctype': None}})
+            {'autoscale': {'invalid-proctype': None}})
         self.assertEqual(response.status_code, 422, response.data)
+        response = self.client.post(
+            f'/v2/apps/{app_id}/settings',
+            {'autoscale': {'invalid_proctype': None}})
+        self.assertEqual(response.status_code, 400, response.data)
 
         # remove a proc type
         response = self.client.post(
@@ -171,41 +229,3 @@ def test_settings_labels(self, mock_requests):
             labels
         )
         self.assertEqual(response.status_code, 422, response.data)
-
-    def test_canaries(self, mock_requests):
-        app_id = self.create_app()
-        app = App.objects.get(id=app_id)
-        expect = ["web", "task", "new", "apps"]
-        for index, scale_type in enumerate(expect):
-            response = self.client.post(
-                '/v2/apps/{}/services'.format(app_id),
-                {
-                    'port': 5000 + index,
-                    'protocol': 'TCP',
-                    'target_port': 5000 + index,
-                    'procfile_type': scale_type
-                }
-            )
-            self.assertEqual(response.status_code, 201, response.data)
-        self.client.post(
-            f'/v2/apps/{app_id}/settings',
-            {'canaries': expect[:2]}
-        )
-        response = self.client.post(
-            f'/v2/apps/{app_id}/settings',
-            {'canaries': expect[2:]}
-        )
-        expect = ["web", "task", "new", "apps"]
-        self.assertEqual(
-            response.json()["canaries"],
-            expect,
-            response.data
-        )
-
-        self.assertEqual(len(app.service_set.all()), 4)
-        response = self.client.delete(
-            f'/v2/apps/{app_id}/settings',
-            {'canaries': ["new", "apps"]}
-        )
-        app_settings = app.appsettings_set.latest()
-        self.assertEqual(app_settings.canaries, ["web", "task"])
diff --git a/rootfs/api/tests/test_build.py b/rootfs/api/tests/test_build.py
index 56b7f7f39..11820bf19 100644
--- a/rootfs/api/tests/test_build.py
+++ b/rootfs/api/tests/test_build.py
@@ -7,11 +7,11 @@
 
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
-from django.test.utils import override_settings
 from unittest import mock
 
 from api.models.build import Build
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from scheduler import KubeException
 
 from api.tests import adapter, DryccTransactionTestCase
@@ -43,28 +43,26 @@ def test_build(self, mock_requests):
         app_id = self.create_app()
 
         # check to see that no initial build was created
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['count'], 0)
 
         # post a new build
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        build_id = str(response.data['uuid'])
         build1 = response.data
         self.assertEqual(response.data['image'], body['image'])
 
         # read the build
-        url = f"/v2/apps/{app_id}/builds/{build_id}"
+        url = f"/v2/apps/{app_id}/build"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         build2 = response.data
         self.assertEqual(build1, build2)
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -85,15 +83,14 @@ def test_response_data(self, mock_requests):
         app_id = self.create_app()
 
         # post an image as a build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         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',
@@ -107,12 +104,12 @@ def test_build_default_containers(self, mock_requests):
         app_id = self.create_app()
 
         # post an image as a build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
@@ -120,7 +117,7 @@ def test_build_default_containers(self, mock_requests):
         # post an image as a build with a procfile
         app_id = self.create_app()
         # post an image as a build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -131,7 +128,7 @@ def test_build_default_containers(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
@@ -139,7 +136,7 @@ def test_build_default_containers(self, mock_requests):
         # start with a new app
         app_id = self.create_app()
         # post a new build with procfile
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -149,7 +146,7 @@ def test_build_default_containers(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
@@ -157,7 +154,7 @@ def test_build_default_containers(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build with procfile
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -170,15 +167,15 @@ def test_build_default_containers(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
+        self.assertEqual(response.data['results'], [])
         # start with a new app
         app_id = self.create_app()
         # post a new build with procfile
 
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -191,7 +188,7 @@ def test_build_default_containers(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
@@ -200,7 +197,7 @@ def test_build_default_containers(self, mock_requests):
         app_id = self.create_app()
         # post a new build with procfile and no routable type
 
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -213,7 +210,6 @@ def test_build_default_containers(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-    @override_settings(DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE=True)
     def test_build_forgotten_procfile(self, mock_requests):
         """
         Test that when a user first posts a build with a Procfile
@@ -224,7 +220,7 @@ def test_build_forgotten_procfile(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build with procfile
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -237,49 +233,53 @@ def test_build_forgotten_procfile(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         # verify web
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
 
         # scale worker
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'worker': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
 
         # verify worker
-        url = f"/v2/apps/{app_id}/pods/worker"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-
         self.assertPodContains(response.data['results'], app_id, 'worker', "v2", "up")
 
         # do another deploy for this time forget Procfile
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        # verify worker is not there
-        url = f"/v2/apps/{app_id}/pods/worker"
+        # verify worker is not there, only web
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 0)
+        self.assertEqual([item for item in response.data['results'] if item["type"] != "web"], [])
 
-        # verify web is not there
-        url = f"/v2/apps/{app_id}/pods/web"
+        # look at the app structure
+        url = f"/v2/apps/{app_id}"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 0)
+        self.assertEqual(response.json()['structure'], {'web': 1})
+
+        # do another deploy for this time forget Procfile
+        url = f"/v2/apps/{app_id}/build"
+        body = {'image': 'autotest/example:v3', 'stack': 'container'}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
 
         # look at the app structure
         url = f"/v2/apps/{app_id}"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.json()['structure'], {'web': 0})
+        self.assertEqual(response.json()['structure'], {'web': 1})
 
-    @override_settings(DRYCC_DEPLOY_PROCFILE_MISSING_REMOVE=False)
     def test_build_no_remove_process(self, mock_requests):
         """
         Specifically test PROCFILE_REMOVE_PROCS_ON_DEPLOY being turned off
@@ -288,117 +288,131 @@ def test_build_no_remove_process(self, mock_requests):
         """
         # start with a new app
         app_id = self.create_app()
+        # Set routable to false
+        response = self.client.post(f'/v2/apps/{app_id}/settings', {'autodeploy': False})
+        self.assertEqual(response.status_code, 201, response.data)
 
         # post a new build with procfile
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
             'stack': 'heroku-18',
             'procfile': {
                 'web': 'node server.js',
+                'task': 'node task.js',
                 'worker': 'node worker.js'
             }
         }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
+        # deploy
+        response = self.client.post(f"/v2/apps/{app_id}/releases/deploy/")
+        self.assertEqual(response.status_code, 204, response.data)
+
         # scale worker
-        url = f"/v2/apps/{app_id}/scale"
-        body = {'worker': 1}
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        body = {'worker': 1, 'task': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
 
         # verify worker
-        url = f"/v2/apps/{app_id}/pods/worker"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
+        self.assertPodContains(response.data['results'], app_id, 'task', "v2", "up")
         self.assertPodContains(response.data['results'], app_id, 'worker', "v2", "up")
 
         # do another deploy for this time forget Procfile
-        url = f"/v2/apps/{app_id}/builds"
-        body = {'image': 'autotest/example', 'stack': 'container'}
+        url = f"/v2/apps/{app_id}/build"
+        body = {'image': 'autotest/example:v10', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        # verify worker is still there
-        url = f"/v2/apps/{app_id}/pods/worker"
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertPodContains(response.data['results'], app_id, 'worker', "v3", "up")
+        # deploy
+        response = self.client.post(f"/v2/apps/{app_id}/releases/deploy/")
+        self.assertEqual(response.status_code, 204, response.data)
 
-        # verify web is still there
-        url = f"/v2/apps/{app_id}/pods/web"
+        # verify web
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(
+            set([item["type"] for item in response.data['results']]),
+            set(["web", "task", "worker"]),
+        )
         self.assertPodContains(response.data['results'], app_id, 'web', "v3", "up")
 
         # look at the app structure
         url = f"/v2/apps/{app_id}"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.json()['structure'], {'web': 1, 'worker': 1})
+        self.assertEqual(response.json()['structure'], {'web': 1, 'task': 1, 'worker': 1})
+
+        # clean worker, repicas 1 not allow
+        response = self.client.post('/v2/apps/{}/ptypes/clean'.format(app_id), {"ptypes": "task"})
+        self.assertEqual(response.status_code, 400, response.data)
 
-        # scale worker to make sure no info was lost
-        url = f"/v2/apps/{app_id}/scale"
-        body = {'worker': 2}  # bump from 1 to 2
+        # scale task
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        body = {'task': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
 
-        # verify worker info
-        url = f"/v2/apps/{app_id}/pods/worker"
+        # clean worker, repicas 0 allow
+        response = self.client.post('/v2/apps/{}/ptypes/clean'.format(app_id), {"ptypes": "task"})
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # look at the app structure
+        url = f"/v2/apps/{app_id}"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertPodContains(response.data['results'], app_id, 'worker', "v3", "up")
+        self.assertEqual(response.json()['structure'], {'web': 1, 'worker': 1})
 
-    @override_settings(DRYCC_DEPLOY_REJECT_IF_PROCFILE_MISSING=True)
-    def test_build_forgotten_procfile_reject(self, mock_requests):
-        """
-        Test that when a user first posts a build with a Procfile
-        and then later without it that missing process are actually
-        scaled down
-        """
-        # start with a new app
-        app_id = self.create_app()
+        # ptypes is a required field
+        response = self.client.post('/v2/apps/{}/ptypes/clean'.format(app_id))
+        self.assertEqual(response.status_code, 400, response.data)
 
-        # post a new build with procfile
-        url = f"/v2/apps/{app_id}/builds"
-        body = {
-            'image': 'autotest/example',
-            'stack': 'heroku-18',
-            'sha': 'a'*40,
-            'procfile': {
-                'web': 'node server.js',
-                'worker': 'node worker.js'
-            }
-        }
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        body = {'worker': 0}
         response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual(response.status_code, 204, response.data)
 
-        # scale worker
-        url = f"/v2/apps/{app_id}/scale"
-        body = {'worker': 1}
-        response = self.client.post(url, body)
+        # clean worker
+        response = self.client.post(
+            '/v2/apps/{}/ptypes/clean'.format(app_id),
+            {"ptypes": "worker"})
         self.assertEqual(response.status_code, 204, response.data)
 
-        # verify worker
-        url = f"/v2/apps/{app_id}/pods/worker"
+        # look at the app structure
+        url = f"/v2/apps/{app_id}"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertPodContains(response.data['results'], app_id, 'worker', "v2", "up")
+        self.assertEqual(response.json()['structure'], {'web': 1})
 
         # do another deploy for this time forget Procfile
-        url = f"/v2/apps/{app_id}/builds"
-        body = {'image': 'autotest/example', 'stack': 'container'}
+        url = f"/v2/apps/{app_id}/build"
+        body = {'image': 'autotest/example:v11', 'stack': 'container'}
         response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 409, response.data)
+        self.assertEqual(response.status_code, 201, response.data)
+
+        # deploy
+        response = self.client.post(f"/v2/apps/{app_id}/releases/deploy/")
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # look at the app structure
+        url = f"/v2/apps/{app_id}"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(response.json()['structure'], {'web': 1})
 
     def test_build_str(self, mock_requests):
         """Test the text representation of a build."""
         app_id = self.create_app()
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -419,7 +433,7 @@ def test_admin_can_create_builds_on_other_apps(self, mock_requests):
 
         # post a new build as admin
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -440,7 +454,7 @@ def test_unauthorized_user_cannot_modify_build(self, mock_requests):
         unauthorized_user = User.objects.get(username='autotest2')
         unauthorized_token = self.get_or_create_token(unauthorized_user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + unauthorized_token)
-        url = '/v2/apps/{}/builds'.format(app_id)
+        url = '/v2/apps/{}/build'.format(app_id)
         body = {'image': 'foo'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 403)
@@ -453,7 +467,7 @@ def test_new_build_does_not_scale_up_automatically(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -466,19 +480,19 @@ def test_new_build_does_not_scale_up_automatically(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
 
         # scale to zero
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
 
         # post another build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -490,56 +504,68 @@ def test_new_build_does_not_scale_up_automatically(self, mock_requests):
         }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         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}/builds"
-        image = '127.0.0.1:5000/autotest/example'
+        url = f"/v2/apps/{app_id}/build"
+        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)
 
         build = Build.objects.get(uuid=response.data['uuid'])
         release = build.app.release_set.latest()
-        self.assertEqual(release.get_deploy_image(PROCFILE_TYPE_WEB), image)
+        self.assertEqual(release.get_deploy_image(PTYPE_WEB), image)
 
         # post an image as a build using registry hostname + port
-        url = f"/v2/apps/{app_id}/builds"
-        image = '127.0.0.1:5000/autotest/example'
+        url = f"/v2/apps/{app_id}/build"
+        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)
 
         build = Build.objects.get(uuid=response.data['uuid'])
         release = build.app.release_set.latest()
-        self.assertEqual(release.get_deploy_image(PROCFILE_TYPE_WEB), image)
+        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()
 
         # post an image as a build using registry hostname
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         image = 'autotest/example'
         response = self.client.post(url, {'image': image, 'stack': 'container'})
         self.assertEqual(response.status_code, 201, response.data)
 
         # add the required PORT information
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'PORT': '80'})}
+        body = {'values': [{"name": "PORT", "group": "global", "value": "80"}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
         # set some registry information
         url = f'/v2/apps/{app_id}/config'
-        body = {'registry': json.dumps({'username': 'bob', 'password': 'zoomzoom'})}
+        body = {'registry': {'web': {'username': 'bob', 'password': 'zoomzoom'}}}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
@@ -548,16 +574,16 @@ def test_build_image_in_registry_with_auth_no_port(self, mock_requests):
         app_id = self.create_app()
 
         # post an image as a build using registry hostname
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         image = 'autotest/example'
         response = self.client.post(url, {'image': image, 'stack': 'container'})
         self.assertEqual(response.status_code, 201, response.data)
 
         # set some registry information
         url = f'/v2/apps/{app_id}/config'
-        body = {'registry': json.dumps({'username': 'bob', 'password': 'zoomzoom'})}
+        body = {'registry': json.dumps({'web': {'username': 'bob', 'password': 'zoomzoom'}})}
         response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 400, response.data)
+        self.assertEqual(response.status_code, 201, response.data)
 
     def test_release_create_failure(self, mock_requests):
         """
@@ -566,7 +592,7 @@ def test_release_create_failure(self, mock_requests):
         app_id = self.create_app()
 
         # deploy app to get a build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -575,7 +601,7 @@ def test_release_create_failure(self, mock_requests):
         with mock.patch('api.models.app.App.deploy') as mock_deploy:
             mock_deploy.side_effect = Exception('Boom!')
 
-            url = f"/v2/apps/{app_id}/builds"
+            url = f"/v2/apps/{app_id}/build"
             body = {'image': 'autotest/example'}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 400, response.data)
@@ -589,7 +615,7 @@ def test_build_deploy_kube_failure(self, mock_requests):
         with mock.patch('scheduler.KubeHTTPClient.deploy') as mock_deploy:
             mock_deploy.side_effect = KubeException('Boom!')
 
-            url = f"/v2/apps/{app_id}/builds"
+            url = f"/v2/apps/{app_id}/build"
             body = {'image': 'autotest/example', 'stack': 'container'}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 201, response.data)
@@ -601,7 +627,7 @@ def test_build_failures(self, mock_requests):
         app = App.objects.get(id=app_id)
 
         # deploy app to get a build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -611,9 +637,8 @@ def test_build_failures(self, mock_requests):
         # create a failed build to check that failed release is created
         with mock.patch('api.models.app.App.deploy') as mock_deploy:
             mock_deploy.side_effect = Exception('Boom!')
-
-            url = f"/v2/apps/{app_id}/builds"
-            body = {'image': 'autotest/example', 'stack': 'container'}
+            url = f"/v2/apps/{app_id}/build"
+            body = {'image': 'autotest/example:v100', 'stack': 'container'}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 201, response.data)
             data = self.client.get(f"/v2/apps/{app_id}/releases/", body).json()
@@ -621,8 +646,7 @@ def test_build_failures(self, mock_requests):
 
         # create a config to see that the new release is created with the last successful build
         url = f"/v2/apps/{app_id}/config"
-
-        body = {'values': json.dumps({'Test': 'test'})}
+        body = {'values': [{"name": "Test", "group": "global", "value": "test"}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         self.assertEqual(app.release_set.latest().version, 4)
@@ -633,7 +657,7 @@ def test_build_validate_procfile(self, mock_requests):
         app_id = self.create_app()
 
         # deploy app with incorrect proctype
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -646,7 +670,7 @@ def test_build_validate_procfile(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
 
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -659,7 +683,7 @@ def test_build_validate_procfile(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
 
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -672,7 +696,7 @@ def test_build_validate_procfile(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
 
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -684,7 +708,7 @@ def test_build_validate_procfile(self, mock_requests):
         }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -697,7 +721,7 @@ def test_build_validate_procfile(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
         # deploy app with empty command
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -710,7 +734,7 @@ def test_build_validate_procfile(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
 
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -725,7 +749,7 @@ def test_build_validate_procfile(self, mock_requests):
 
     def test_dryccfile_ok(self, mock_requests):
         app_id = self.create_app()
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         default_image = "autotest/example"
         run_image = "127.0.0.1:7070/myapp/run:git-123fsa1"
         web_image = "127.0.0.1:7070/myapp/web:git-123fsa1"
@@ -734,28 +758,44 @@ def test_dryccfile_ok(self, mock_requests):
             'image': 'autotest/example',
             'stack': 'heroku-18',
             'sha': 'a'*40,
-            'dryccfile': {
-                "build": {
-                    "docker": {"web": "Dockerfile", "worker": "worker/Dockerfile"},
-                    "config": {"RAILS_ENV": "development", "FOO": "bar"}
-                },
-                "run": {
-                    "command": ["./deployment-tasks.sh"],
-                    "image": run_image,
-                },
-                "deploy": {
-                    "web": {
-                        "command": ["bash", "-c"],
-                        "args": ["bundle exec puma -C config/puma.rb"],
-                        "image": "127.0.0.1:7070/myapp/web:git-123fsa1"
+            "dryccfile": {
+                "pipeline": {
+                    "web.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "web",
+                        "build": {
+                            "docker": "Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "run": {"command": ["./deployment-tasks.sh"], "image": run_image},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["bundle exec puma -C config/puma.rb"],
+                            "image": "127.0.0.1:7070/myapp/web:git-123fsa1",
+                        },
                     },
-                    "worker": {
-                        "command": ["bash", "-c"],
-                        "args": ["python myworker.py"],
-                        "image": "127.0.0.1:7070/myapp/worker:git-123fsa1"
-                    }
-                }
-            }
+                    "worker.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker",
+                        "build": {
+                            "docker": "worker/Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "run": {"command": ["./deployment-tasks.sh"], "image": run_image},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["python myworker.py"],
+                            "image": "127.0.0.1:7070/myapp/worker:git-123fsa1"
+                        },
+                    },
+                },
+            },
         }
         with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
             mock_kube.return_value = ['up', 'down']
@@ -773,7 +813,9 @@ def test_dryccfile_ok(self, mock_requests):
             self.assertEqual(release["state"], "succeed", data)
             self.assertEqual(release["version"], 2, data)
             release_obj = app.release_set.filter(version=release["version"])[0]
-            self.assertEqual(release_obj.get_run_image(), run_image, data)
+            runners = release_obj.get_runners(["web"])
+            self.assertEqual(len(runners), 1)
+            self.assertEqual(runners[0]["image"], run_image, data)
             self.assertEqual(release_obj.get_deploy_image("web"), web_image, data)
             self.assertEqual(release_obj.get_deploy_image("worker"), worker_image, data)
             self.assertEqual(release_obj.get_deploy_image("noexist"), default_image, data)
@@ -784,40 +826,51 @@ def test_dryccfile_format(self, mock_requests):
             'stack': 'heroku-18',
             'sha': 'a'*40,
             'dryccfile': {
-                "build": {
-                    "docker": {"web": "Dockerfile", "worker": "worker/Dockerfile"},
-                    "config": {"RAILS_ENV": "development", "FOO": "bar"}
+                "pipeline": {
+                    "web.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "web",
+                        "build": {
+                            "docker": "Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                    },
+                    "worker.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker",
+                        "build": {
+                            "docker": "worker/Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                    },
                 },
-            }
+            },
         }
         with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
             mock_kube.return_value = ['up', 'down']
             app_id = self.create_app()
-            url = f"/v2/apps/{app_id}/builds"
+            url = f"/v2/apps/{app_id}/build"
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 400, response.data)
-            body['dryccfile']['deploy'] = {
-                "web-canary": {
-                    'image': "127.0.0.1/cat/cat"
-                }
-            }
+            body['dryccfile']['pipeline']["web.yaml"]['deploy'] = {'image': "127.0.0.1/cat/cat"}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 400, response.data)
-            del body['dryccfile']['deploy']['web-canary']
-            body['dryccfile']['deploy'] = {
-                "web": {
-                    'image': "127.0.0.1/cat/cat"
-                }
-            }
+            body['dryccfile']['pipeline']["worker.yaml"]['deploy'] = {'image': "127.0.0.1/cat/cat"}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 201, response.data)
-            body['dryccfile']['run'] = {
-                'command': ["bash", "-c"],
-                'args': ["ls /"]
-            }
+            body['dryccfile']['pipeline']["web.yaml"]['run'] = {
+                'command': ["bash", "-c"], 'args': ["ls /"]}
+            body['dryccfile']['pipeline']["worker.yaml"]['run'] = {
+                'command': ["bash", "-c"], 'args': ["ls /"]}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 201, response.data)
-            body['dryccfile']['deploy'] = {}
+            body['dryccfile']['pipeline']["web.yaml"] = {}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 400, response.data)
             body['dryccfile'] = {}
diff --git a/rootfs/api/tests/test_canary.py b/rootfs/api/tests/test_canary.py
deleted file mode 100644
index fcb55cc53..000000000
--- a/rootfs/api/tests/test_canary.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import json
-from django.contrib.auth import get_user_model
-from django.core.cache import cache
-
-from api.models.app import App
-from api.tests import adapter, DryccTransactionTestCase
-import requests_mock
-
-User = get_user_model()
-
-
-@requests_mock.Mocker(real_http=True, adapter=adapter)
-class CanaryTest(DryccTransactionTestCase):
-
-    """Tests push notification from build system"""
-
-    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)
-
-    def tearDown(self):
-        # make sure every test has a clean slate for k8s mocking
-        cache.clear()
-
-    def pre_data(self):
-        app_id = self.create_app()
-        # check that updating config rolls a new release
-        url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('NEW_URL1', response.data['values'])
-        # check that updating the build rolls a new release
-        url = f'/v2/apps/{app_id}/builds'
-        body = {'image': 'autotest/example:v1', 'stack': 'container'}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
-        self.assertEqual(response.data['image'], body['image'])
-        # create v2 release
-        url = f'/v2/apps/{app_id}/builds'
-        body = {'image': 'autotest/example:v2', 'stack': 'container'}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
-        self.assertEqual(response.data['image'], body['image'])
-        # get deployments
-        app = App.objects.get(id=app_id)
-        response = app.scheduler().deployments.get(app_id)
-        self.assertEqual(len(response.json()["items"]), 1)
-        # add canary
-        self.client.post(
-            f'/v2/apps/{app_id}/settings',
-            {'canaries': ["web"]}
-        )
-        # add v3 release
-        url = f'/v2/apps/{app_id}/builds'
-        body = {'image': 'autotest/example:v3', 'stack': 'container'}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
-        self.assertEqual(response.data['image'], body['image'])
-        return app
-
-    def test_release(self, mock_requests):
-        app = self.pre_data()
-        # get deployments
-        response = app.scheduler().deployments.get(app.id)
-        self.assertEqual(len(response.json()["items"]), 2)
-        response = self.client.post(f'/v2/apps/{app.id}/canary/release/')
-        self.assertEqual(response.status_code, 201)
-        response = app.scheduler().deployments.get(app.id)
-        self.assertEqual(len(response.json()["items"]), 1)
-        self.assertEqual(
-            response.json()["items"][0]["spec"]["template"]["spec"]["containers"][0]["image"],
-            'autotest/example:v3',
-        )
-
-    def test_rollback(self, mock_requests):
-        app = self.pre_data()
-        response = app.scheduler().deployments.get(app.id)
-        self.assertEqual(len(response.json()["items"]), 2)
-        response = self.client.post(f'/v2/apps/{app.id}/canary/rollback/')
-        self.assertEqual(response.status_code, 201)
-        response = app.scheduler().deployments.get(app.id)
-        self.assertEqual(len(response.json()["items"]), 1)
-        self.assertEqual(
-            response.json()["items"][0]["spec"]["template"]["spec"]["containers"][0]["image"],
-            'autotest/example:v2',
-        )
diff --git a/rootfs/api/tests/test_certificate.py b/rootfs/api/tests/test_certificate.py
index db6b11c3d..ccb9d5446 100644
--- a/rootfs/api/tests/test_certificate.py
+++ b/rootfs/api/tests/test_certificate.py
@@ -2,8 +2,15 @@
 from django.core.cache import cache
 from django.core.exceptions import SuspiciousOperation
 
+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()
 
@@ -18,8 +25,9 @@ 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.url = '/v2/certs'
+        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'
 
         with open('{}/certs/{}.key'.format(TEST_ROOT, self.domain)) as f:
@@ -32,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(
@@ -144,17 +180,17 @@ def test_delete_certificate(self):
         """Destroying a certificate should generate a 204 response"""
         Certificate.objects.create(
             name='random-test-cert',
-            owner=self.user,
+            app=self.app,
             common_name='autotest.example.com',
             certificate=self.cert,
             key=self.key
         )
-        url = '/v2/certs/random-test-cert'
+        url = f'/v2/apps/{self.app.id}/certs/random-test-cert'
         response = self.client.delete(url)
         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,
             {
@@ -164,22 +200,22 @@ 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',
                 key='i am bad data as well'
             )
 
     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,
             {
@@ -189,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 a4511605f..604a9f4b4 100644
--- a/rootfs/api/tests/test_certificate_use_case_1.py
+++ b/rootfs/api/tests/test_certificate_use_case_1.py
@@ -1,7 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.certificate import Certificate
 from api.models.domain import Domain
 from api.tests import TEST_ROOT, DryccTestCase
@@ -23,10 +24,11 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.url = '/v2/certs'
-        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', procfile_type=PROCFILE_TYPE_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:
@@ -146,13 +148,13 @@ 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,
             key=self.key
         )
 
-        url = '/v2/certs/{}'.format(self.name)
+        url = f'/v2/apps/{self.app.id}/certs/{self.name}/'
         response = self.client.delete(url)
         self.assertEqual(response.status_code, 204, response.data)
 
@@ -189,7 +191,7 @@ def test_delete_certificate_with_attached_domain(self):
         self.assertEqual(domain.certificate.name, self.name)
 
         # Delete certificate
-        url = '/v2/certs/{}'.format(self.name)
+        url = f'/v2/apps/{self.app.id}/certs/{self.name}/'
         response = self.client.delete(url)
         self.assertEqual(response.status_code, 204, response.data)
 
diff --git a/rootfs/api/tests/test_certificate_use_case_2.py b/rootfs/api/tests/test_certificate_use_case_2.py
index 552026559..c74845291 100644
--- a/rootfs/api/tests/test_certificate_use_case_2.py
+++ b/rootfs/api/tests/test_certificate_use_case_2.py
@@ -1,7 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.certificate import Certificate
 from api.models.domain import Domain
 from api.tests import TEST_ROOT, DryccTestCase
@@ -23,13 +24,14 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.url = '/v2/certs'
-        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', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='foo.com', ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         # only foo.com has a cert
@@ -131,11 +133,11 @@ 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'],
                 key=certificate['key']
             )
-            url = '/v2/certs/{}'.format(certificate['name'])
+            url = f'/v2/apps/{self.app.id}/certs/{certificate['name']}/'
             response = self.client.delete(url)
             self.assertEqual(response.status_code, 204, response.data)
diff --git a/rootfs/api/tests/test_certificate_use_case_3.py b/rootfs/api/tests/test_certificate_use_case_3.py
index c4dd0b890..7eedfc859 100644
--- a/rootfs/api/tests/test_certificate_use_case_3.py
+++ b/rootfs/api/tests/test_certificate_use_case_3.py
@@ -1,7 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.certificate import Certificate
 from api.models.domain import Domain
 from api.tests import TEST_ROOT, DryccTestCase
@@ -23,13 +24,14 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.url = '/v2/certs'
-        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', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='foo.com', ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         self.certificates = {}
@@ -133,11 +135,11 @@ 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'],
                 key=certificate['key']
             )
-            url = '/v2/certs/{}'.format(certificate['name'])
+            url = f'/v2/apps/{self.app.id}/certs/{certificate['name']}/'
             response = self.client.delete(url)
             self.assertEqual(response.status_code, 204, response.data)
diff --git a/rootfs/api/tests/test_certificate_use_case_4.py b/rootfs/api/tests/test_certificate_use_case_4.py
index aa8802400..573cfa571 100644
--- a/rootfs/api/tests/test_certificate_use_case_4.py
+++ b/rootfs/api/tests/test_certificate_use_case_4.py
@@ -1,7 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.certificate import Certificate
 from api.models.domain import Domain
 from api.tests import TEST_ROOT, DryccTestCase
@@ -23,16 +24,17 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.url = '/v2/certs'
-        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',
-                procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='*.foo.com',
+                ptype=PTYPE_WEB),
             'foo.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='foo.com', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='foo.com', ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         self.certificates = {}
@@ -167,8 +169,8 @@ def test_certificate_attach_overwrite(self):
             '{}/{}/domain/'.format(self.url, 'bar-com'),
             {'domain': 'foo.com'}
         )
-        # Should be a 409 Conflict since it already existed
-        self.assertEqual(response.status_code, 409)
+        # allow re attach to update
+        self.assertEqual(response.status_code, 201)
 
         # Assert that domain and cert are still the original
         response = self.client.get(
@@ -179,7 +181,7 @@ def test_certificate_attach_overwrite(self):
         expected = {
             'name': 'foo-com',
             'common_name': 'foo.com',
-            'domains': ['foo.com']
+            'domains': []
         }
         for key, value in list(expected.items()):
             self.assertEqual(
@@ -196,7 +198,7 @@ def test_certificate_attach_overwrite(self):
         expected = {
             'name': 'bar-com',
             'common_name': 'bar.com',
-            'domains': []
+            'domains': ['foo.com']
         }
         for key, value in list(expected.items()):
             self.assertEqual(
@@ -217,11 +219,11 @@ 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'],
                 key=certificate['key']
             )
-            url = '/v2/certs/{}'.format(certificate['name'])
+            url = f'/v2/apps/{self.app.id}/certs/{certificate['name']}/'
             response = self.client.delete(url)
             self.assertEqual(response.status_code, 204, response.data)
diff --git a/rootfs/api/tests/test_certificate_use_case_5.py b/rootfs/api/tests/test_certificate_use_case_5.py
index 5667a48d8..1a060421b 100644
--- a/rootfs/api/tests/test_certificate_use_case_5.py
+++ b/rootfs/api/tests/test_certificate_use_case_5.py
@@ -1,7 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.certificate import Certificate
 from api.models.domain import Domain
 from api.tests import TEST_ROOT, DryccTestCase
@@ -23,17 +24,18 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.url = '/v2/certs'
-        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', procfile_type=PROCFILE_TYPE_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',
-                procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='*.foo.com',
+                ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', procfile_type=PROCFILE_TYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         self.certificates = {}
@@ -163,13 +165,13 @@ 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'],
                 key=certificate['key']
             )
 
             # Remove certificate
-            url = '/v2/certs/{}'.format(certificate['name'])
+            url = f'/v2/apps/{self.app.id}/certs/{certificate['name']}/'
             response = self.client.delete(url)
             self.assertEqual(response.status_code, 204, response.data)
diff --git a/rootfs/api/tests/test_config.py b/rootfs/api/tests/test_config.py
index ecf562c9f..c9579df42 100644
--- a/rootfs/api/tests/test_config.py
+++ b/rootfs/api/tests/test_config.py
@@ -4,16 +4,16 @@
 
 Run the tests with "./manage.py test api"
 """
-import json
+import copy
 from io import StringIO
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
-from django.conf import settings
 from django.core.management import call_command
 
 from unittest import mock
 
-from api.models.app import App, PROCFILE_TYPE_RUN, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_RUN, PTYPE_WEB
 from api.models.config import Config
 from api.serializers import CONFIG_LIMITS_MISMATCH_MSG
 from api.models.build import Build
@@ -35,15 +35,10 @@ 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):
-        # Restore default tags to empty string
-        settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
         # make sure every test has a clean slate for k8s mocking
         cache.clear()
 
@@ -59,108 +54,303 @@ def test_config(self, mock_requests):
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('values', response.data)
-        self.assertEqual(response.data['values'], {})
+        self.assertEqual(response.data['values'], [])
         config1 = response.data
 
         # set an initial config value
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        value1 = {"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         config2 = response.data
         self.assertNotEqual(config1['uuid'], config2['uuid'])
-        self.assertIn('NEW_URL1', response.data['values'])
+        self.assertEqual(body['values'], response.data['values'])
 
         # read the config
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         config3 = response.data
         self.assertEqual(config2, config3)
-        self.assertIn('NEW_URL1', response.data['values'])
+        self.assertEqual(body['values'], response.data['values'])
 
         # set an additional config value
-        body = {'values': json.dumps({'NEW_URL2': 'http://localhost:8080/'})}
+        value2 = {"name": "NEW_URL2", "value": "http://localhost:8080/", "group": "global"}
+        body = {'values': [value2]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         config3 = response.data
         self.assertNotEqual(config2['uuid'], config3['uuid'])
-        self.assertIn('NEW_URL1', response.data['values'])
-        self.assertIn('NEW_URL2', response.data['values'])
+        self.assertEqual([value1, value2], response.data['values'])
 
         # read the config again
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         config4 = response.data
         self.assertEqual(config3, config4)
-        self.assertIn('NEW_URL1', response.data['values'])
-        self.assertIn('NEW_URL2', response.data['values'])
+        self.assertEqual([value1, value2], response.data['values'])
 
         # unset a config value
-        body = {'values': json.dumps({'NEW_URL2': None})}
+        value2['value'] = None
+        body = {'values': [value2]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         config5 = response.data
         self.assertNotEqual(config4['uuid'], config5['uuid'])
-        self.assertNotIn('NEW_URL2', json.dumps(response.data['values']))
+        self.assertEqual([value1], response.data['values'])
 
         # unset all config values
-        body = {'values': json.dumps({'NEW_URL1': None})}
+        value1['value'] = None
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertNotIn('NEW_URL1', json.dumps(response.data['values']))
+        self.assertEqual([], response.data['values'])
 
         # set a port and then unset it to make sure validation ignores the unset
-        body = {'values': json.dumps({'PORT': '5000'})}
+        value3 = {"name": "PORT", "value": "5000", "group": "global"}
+        body = {'values': [value3]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('PORT', response.data['values'])
+        self.assertEqual([value3], response.data['values'])
 
-        body = {'values': json.dumps({'PORT': None})}
+        value3['value'] = None
+        body = {'values': [value3]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual([], response.data['values'])
+        # repeat settings
+        value2['value'] = "VALUE2"
+        value3['value'] = None
+        body = {'values': [value2, value3]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual([value2], response.data['values'])
+        # set empty env
+        value1['value'] = ''
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertNotIn('PORT', response.data['values'])
+        self.assertEqual([value2, value1], response.data['values'])
 
-        # disallow put/patch/delete
+        # disallow put/patch
         response = self.client.put(url)
         self.assertEqual(response.status_code, 405, response.data)
         response = self.client.patch(url)
         self.assertEqual(response.status_code, 405, response.data)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 405, response.data)
         return config5
 
-    def test_default_tags(self, mock_requests):
-        settings.DRYCC_DEFAULT_CONFIG_TAGS = '{"ssd": "true"}'
+    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)
-        expected = {
-            'owner': self.user.username,
-            'app': app_id,
-            'values': {},
-            'limits': {
-                  PROCFILE_TYPE_RUN: 'std1.large.c1m1',
-                  PROCFILE_TYPE_WEB: 'std1.large.c1m1'
-            },
-            'tags': {'ssd': 'true'},
-            'registry': {}
-        }
-        self.assertEqual(response.data, expected | response.data)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 3)
 
-        # make sure changes not drop tags
-        body = {'values': json.dumps({'PORT': '5001'})}
+        # 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)
-        expected = {
-            'owner': self.user.username,
-            'app': app_id,
-            'values': {'PORT': '5001'},
-            'limits': {
-                PROCFILE_TYPE_RUN: 'std1.large.c1m1',
-                PROCFILE_TYPE_WEB: 'std1.large.c1m1'
-            },
-            'tags': {'ssd': 'true'},
-            'registry': {}
-        }
-        self.assertEqual(response.data, response.data | expected)
+        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
+        url = f"/v2/apps/{app_id}/config"
+        web_registry = {'web': {'username': 'admin', 'password': 'admin'}}
+        body = {'registry': web_registry}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+        url = '/v2/apps/{}/config'.format(app_id)
+        response = self.client.get(url)
+        self.assertEqual(response.data['registry'], web_registry, response.data)
+
+        task_registry = {'task': {'username': 'admin', 'password': 'admin'}}
+        body = {'registry': task_registry}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+        url = '/v2/apps/{}/config'.format(app_id)
+        response = self.client.get(url)
+        all_registry = {}
+        all_registry.update(web_registry)
+        all_registry.update(task_registry)
+        self.assertEqual(response.data['registry'], all_registry, response.data)
+
+        # delete task username
+        task_registry = {'task': {'username': None, 'password': 'admin'}}
+        body = {'registry': task_registry}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        url = '/v2/apps/{}/config'.format(app_id)
+        response = self.client.get(url)
+        all_registry = {}
+        all_registry.update(web_registry)
+        all_registry.update(task_registry)
+        del all_registry['task']['username']
+        self.assertEqual(response.data['registry'], all_registry, response.data)
+
+        # delete task registry
+        task_registry = {'task': None}
+        body = {'registry': task_registry}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        url = '/v2/apps/{}/config'.format(app_id)
+        response = self.client.get(url)
+        self.assertEqual(response.data['registry'], web_registry, response.data)
+
+    def test_values_refs(self, mock_requests):
+        app_id = self.create_app()
+        url = f"/v2/apps/{app_id}/config"
+        values = [{"name": "DEBUG", "value": "true", "group": "global"}]
+        body = {'values': values}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        self.assertEqual(config.envs("web"), {'DEBUG': 'true'})
+        self.assertEqual(config.envs("task"), {'DEBUG': 'true'})
+
+        # add test error, changed nothing
+        values = [
+            {"name": "DEBUG", "value": "true", "group": "global"},
+        ]
+        body = {'values': values}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 409, response.data)
+
+        # add values ok
+        values = [
+            {"name": "DEBUG", "value": "true", "group": "mytask1"},
+            {"name": "APP", "value": "task2", "group": "mytask2"},
+            {"name": "DEBUG", "value": "false", "group": "global"},
+        ]
+        body = {'values': values}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        self.assertEqual(config.envs("web"), {'DEBUG': 'false'})
+        self.assertEqual(config.envs("task"), {'DEBUG': 'false'})
+
+        # add values_refs
+        body = {'values_refs': {"task": ["mytask1", "mytask2"]}}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        self.assertEqual(config.envs("web"), {'DEBUG': 'false'})
+        self.assertEqual(config.envs("task"), {'DEBUG': 'true', 'APP': 'task2'})
+
+        # add new item for mytask
+        values = [
+            {"name": "VERSION", "value": "1.0.1", "group": "mytask1"},
+        ]
+        body = {'values': values}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        self.assertEqual(config.envs("web"), {'DEBUG': 'false'})
+        self.assertEqual(
+            config.envs("task"), {'DEBUG': 'true', 'VERSION': '1.0.1', 'APP': 'task2'})
+
+        # remove tas values_refs
+        body = {'values_refs': {'task': ['mytask1']}}
+        response = self.client.delete(url, body)
+        self.assertEqual(response.status_code, 200, response.data)
+        url = '/v2/apps/{}/config'.format(app_id)
+        response = self.client.get(url)
+        self.assertEqual(response.data['values_refs'], {"task": ["mytask2"]}, response.data)
 
     def test_response_data(self, mock_requests):
         """Test that the serialized response contains only relevant data."""
@@ -169,20 +359,19 @@ def test_response_data(self, mock_requests):
         url = f"/v2/apps/{app_id}/config"
 
         # set an initial config value
-        body = {'values': json.dumps({'PORT': '5000'})}
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         for key in response.data:
-            self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values',
-                                'typed_values', 'limits', 'tags', 'registry', 'healthcheck',
-                                'lifecycle_post_start', 'lifecycle_pre_stop',
-                                'termination_grace_period'])
+            self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'values',
+                                'values_refs', 'limits', 'tags', 'registry', 'healthcheck',
+                                'lifecycle', 'termination_grace_period'])
         expected = {
-            'owner': self.user.username,
             'app': app_id,
-            'values': {'PORT': '5000'},
+            'values': [value1],
             'limits': {
-                PROCFILE_TYPE_RUN: 'std1.large.c1m1',
-                PROCFILE_TYPE_WEB: 'std1.large.c1m1'
+                PTYPE_RUN: 'std1.large.c1m1',
+                PTYPE_WEB: 'std1.large.c1m1'
             },
             'tags': {},
             'registry': {}
@@ -194,34 +383,32 @@ def test_response_data_types_converted(self, mock_requests):
         app_id = self.create_app()
 
         url = f"/v2/apps/{app_id}/config"
-
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
         body = {
-            'values': json.dumps({'PORT': 5000}),
+            'values': [value1],
             'limits': {
-                PROCFILE_TYPE_WEB: 'std1.large.c1m2',
+                PTYPE_WEB: 'std1.large.c1m2',
             }
         }
         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',
-                                'typed_values', 'tags', 'registry', 'healthcheck',
-                                'lifecycle_post_start', 'lifecycle_pre_stop',
-                                'termination_grace_period'])
+            self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'values', 'limits',
+                                'values_refs', 'tags', 'registry', 'healthcheck',
+                                'lifecycle', 'termination_grace_period'])
         expected = {
-            'owner': self.user.username,
             'app': app_id,
-            'values': {'PORT': '5000'},
+            'values': [value1],
             'limits': {
-                PROCFILE_TYPE_RUN: 'std1.large.c1m1',
-                PROCFILE_TYPE_WEB: 'std1.large.c1m2'
+                PTYPE_RUN: 'std1.large.c1m1',
+                PTYPE_WEB: 'std1.large.c1m2'
             },
             'tags': {},
             'registry': {}
         }
         self.assertEqual(response.data, expected | response.data)
 
-        body = {'limits': {PROCFILE_TYPE_WEB: "not-exist"}}
+        body = {'limits': {PTYPE_WEB: "not-exist"}}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
         self.assertEqual(
@@ -237,17 +424,18 @@ def test_config_set_same_key(self, mock_requests):
         url = f"/v2/apps/{app_id}/config"
 
         # set an initial config value
-        body = {'values': json.dumps({'PORT': '5000'})}
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('PORT', response.data['values'])
+        self.assertEqual([value1], response.data['values'])
 
         # reset same config value
-        body = {'values': json.dumps({'PORT': '5001'})}
+        value1['value'] = '5001'
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('PORT', response.data['values'])
-        self.assertEqual(response.data['values']['PORT'], '5001')
+        self.assertEqual([value1], response.data['values'])
 
     def test_config_set_unicode(self, mock_requests):
         """
@@ -257,23 +445,24 @@ def test_config_set_unicode(self, mock_requests):
         url = f"/v2/apps/{app_id}/config"
 
         # set an initial config value
-        body = {'values': json.dumps({'POWERED_BY': 'Деис'})}
+        value1 = {"name": "POWERED_BY", "value": "Деис", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('POWERED_BY', response.data['values'])
+        self.assertEqual([value1], response.data['values'])
         # reset same config value
-        body = {'values': json.dumps({'POWERED_BY': 'Кроликов'})}
+        value1['value'] = 'Кроликов'
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('POWERED_BY', response.data['values'])
-        self.assertEqual(response.data['values']['POWERED_BY'], 'Кроликов')
+        self.assertEqual([value1], response.data['values'])
 
         # set an integer to test unicode regression
-        body = {'values': json.dumps({'INTEGER': 1})}
+        value2 = {"name": "INTEGER", "value": "1", "group": "global"}
+        body = {'values': [value2]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('INTEGER', response.data['values'])
-        self.assertEqual(response.data['values']['INTEGER'], '1')
+        self.assertEqual([value1, value2], response.data['values'])
 
     def test_config_str(self, mock_requests):
         """Test the text representation of a node."""
@@ -287,11 +476,14 @@ def test_valid_config_keys(self, mock_requests):
         keys = ("FOO", "_foo", "f001", "FOO_BAR_BAZ_")
         app_id = self.create_app()
         url = f'/v2/apps/{app_id}/config'
+        values = []
         for k in keys:
-            body = {'values': json.dumps({k: "testvalue"})}
+            value1 = {"name": k, "value": "testvalue", "group": "global"}
+            values.append(value1)
+            body = {'values': [value1]}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 201)
-            self.assertIn(k, response.data['values'])
+            self.assertEqual(values, response.data['values'])
 
     def test_config_deploy_failure(self, mock_requests):
         """
@@ -300,7 +492,7 @@ def test_config_deploy_failure(self, mock_requests):
         app_id = self.create_app()
 
         # deploy app to get a build
-        url = "/v2/apps/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -309,18 +501,24 @@ def test_config_deploy_failure(self, mock_requests):
         with mock.patch('api.models.app.App.deploy') as mock_deploy:
             mock_deploy.side_effect = Exception('Boom!')
             url = f'/v2/apps/{app_id}/config'
-            body = {'values': json.dumps({'test': "testvalue"})}
+            value1 = {"name": "test", "value": "testvalue", "group": "global"}
+            body = {'values': [value1]}
             response = self.client.post(url, body)
-            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.status_code, 201)
+            app = App.objects.get(id=app_id)
+            release = app.release_set.latest()
+            self.assertEqual(release.failed, True)
+            self.assertEqual(release.conditions[0]['exception'], "Exception: Boom!")
 
     def test_invalid_config_keys(self, mock_requests):
         """Test that invalid config keys are rejected.
         """
-        keys = ("123", "../../foo", "FOO/", "FOO-BAR")
+        keys = ("$123", "../../foo", "FOO/", "*FOO-BAR")
         app_id = self.create_app()
         url = f'/v2/apps/{app_id}/config'
         for k in keys:
-            body = {'values': json.dumps({k: "testvalue"})}
+            value1 = {"name": k, "value": "testvalue", "group": "global"}
+            body = {'values': [value1]}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 400)
 
@@ -330,13 +528,13 @@ def test_invalid_config_values(self, mock_requests):
         Right now only PORT is checked
         """
         data = [
-            {'field': 'PORT', 'value': 'dog'},
-            {'field': 'PORT', 'value': 99999}
+            {"name": "PORT", "value": "dog", "group": "global"},
+            {"name": "PORT", "value": "99999", "group": "global"}
         ]
         app_id = self.create_app()
         url = f'/v2/apps/{app_id}/config'
         for row in data:
-            body = {'values': json.dumps({row['field']: row['value']})}
+            body = {'values': [row]}
             response = self.client.post(url, body)
             self.assertEqual(response.status_code, 400, response.data)
 
@@ -353,20 +551,13 @@ def test_admin_can_create_config_on_other_apps(self, mock_requests):
 
         # set an initial config value
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        body = {'values': json.dumps({'PORT': '5000'})}
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('PORT', response.data['values'])
+        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.
@@ -380,7 +571,8 @@ def test_unauthorized_user_cannot_modify_config(self, mock_requests):
         unauthorized_token = self.get_or_create_token(unauthorized_user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + unauthorized_token)
         url = '/v2/apps/{}/config'.format(app_id)
-        body = {'values': {'FOO': 'bar'}}
+        value1 = {"name": "FOO", "value": "bar", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 403)
 
@@ -395,7 +587,7 @@ def test_config_failures(self, mock_requests):
         app = App.objects.get(id=app_id)
 
         # deploy app to get a build
-        url = "/v2/apps/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -403,24 +595,29 @@ def test_config_failures(self, mock_requests):
 
         # set an initial config value
         url = f"/v2/apps/{app_id}/config"
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        value1 = {"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}
+        body = {'values': [value1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('NEW_URL1', response.data['values'])
+        self.assertEqual([value1], response.data['values'])
         success_config = app.release_set.latest().config
 
         # create a failed config to check that failed release is created
         with mock.patch('api.models.app.App.deploy') as mock_deploy:
             mock_deploy.side_effect = Exception('Boom!')
             url = f'/v2/apps/{app_id}/config'
-            body = {'values': json.dumps({'test': "testvalue"})}
+            value1 = {"name": "test", "value": "testvalue", "group": "global"}
+            body = {'values': [value1]}
             response = self.client.post(url, body)
-            self.assertEqual(response.status_code, 400)
+            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'], "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
-        url = "/v2/apps/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -431,12 +628,13 @@ def test_config_failures(self, mock_requests):
     def test_unset_limits(self, mock_requests):
         app_id = self.create_app()
         url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
         body = {
-            'values': json.dumps({'PORT': 5000}),
+            'values': [value1],
             'limits': {
                 "task": 'std1.large.c2m4',
-                PROCFILE_TYPE_RUN: 'std1.large.c2m4',
-                PROCFILE_TYPE_WEB: 'std1.large.c2m4',
+                PTYPE_RUN: 'std1.large.c2m4',
+                PTYPE_WEB: 'std1.large.c2m4',
             },
         }
         response = self.client.post(url, body)
@@ -455,27 +653,26 @@ def test_unset_limits(self, mock_requests):
         response = self.client.get(url)
         self.assertEqual(
             response.data["limits"],
-            {PROCFILE_TYPE_RUN: 'std1.large.c2m4', PROCFILE_TYPE_WEB: 'std1.large.c2m4'},
+            {PTYPE_RUN: 'std1.large.c2m4', PTYPE_WEB: 'std1.large.c2m4'},
         )
 
     def test_unset_limits_error(self, mock_requests):
         app_id = self.create_app()
         url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
         body = {
-            'values': json.dumps({'PORT': 5000}),
+            'values': [value1],
             'limits': {
                 "task": 'std1.large.c2m4',
-                PROCFILE_TYPE_RUN: 'std1.large.c2m4',
-                PROCFILE_TYPE_WEB: 'std1.large.c2m4',
+                PTYPE_RUN: 'std1.large.c2m4',
+                PTYPE_WEB: 'std1.large.c2m4',
             },
         }
         response = self.client.post(url, body)
         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={
@@ -488,13 +685,12 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
         # unset error
         body = {
             'limits': {
@@ -506,12 +702,12 @@ def test_unset_limits_error(self, mock_requests):
         self.assertEqual(str(response.data["detail"]), "no-exists does not exist under limits")
         # scale up
         body = {'web': 3}
-        response = self.client.post(f"/v2/apps/{app_id}/scale", body)
+        response = self.client.post(f"/v2/apps/{app_id}/ptypes/scale", body)
         self.assertEqual(response.status_code, 204, response.data)
 
         body = {
             'limits': {
-                  PROCFILE_TYPE_WEB: None,
+                  PTYPE_WEB: None,
             },
         }
         response = self.client.post(url, body)
@@ -522,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(),
@@ -534,11 +730,12 @@ def test_measure_config(self, *args, **kwargs):
         # create
         app_id = self.create_app()
         url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
         body = {
-            'values': json.dumps({'PORT': 5000}),
+            'values': [value1],
             'limits': {
-                  PROCFILE_TYPE_RUN: 'std1.large.c2m4',
-                  PROCFILE_TYPE_WEB: 'std1.large.c2m4',
+                  PTYPE_RUN: 'std1.large.c2m4',
+                  PTYPE_WEB: 'std1.large.c2m4',
             },
         }
         response = self.client.post(url, body)
@@ -551,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={
@@ -566,18 +761,18 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
         body = {
-            'values': json.dumps({'PORT': 5000}),
+            'values': [value1],
             'limits': {
-                  PROCFILE_TYPE_RUN: 'std1.large.c2m4',
-                  PROCFILE_TYPE_WEB: 'std1.large.c2m4',
+                  PTYPE_RUN: 'std1.large.c2m4',
+                  PTYPE_WEB: 'std1.large.c2m4',
             },
         }
         url = f"/v2/apps/{app_id}/config"
@@ -596,24 +791,303 @@ def test_config_set_typed_values(self, mock_requests):
         url = f"/v2/apps/{app_id}/config"
 
         # set an initial config value
-        body = {'typed_values': {'web': {'PORT': '5000'}}}
+        body = {'values': [{"name": "PORT", "value": "5000", "ptype": "web"}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('PORT', response.data['typed_values']['web'])
+        self.assertIn({"name": "PORT", "value": "5000", "ptype": "web"}, response.data['values'])
 
         # reset same config value
-        body = {'typed_values': {'web': {'PORT': '5001'}}}
+        body = {'values': [{"name": "PORT", "value": "5001", "ptype": "web"}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('PORT', response.data['typed_values']['web'])
-        self.assertEqual(response.data['typed_values']['web']['PORT'], '5001')
+        self.assertIn({"name": "PORT", "value": "5001", "ptype": "web"}, response.data['values'])
         # unset PORT
-        body = {'typed_values': {'web': {'PORT': None}}}
+        body = {'values': [{"name": "PORT", "value": None, "ptype": "web"}]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual(response.data['values'], [], response.data)
+
+    def test_config_version(self, mock_requests):
+        """
+        Test that config sets on the same key function properly
+        """
+        app_id = self.create_app()
+        url = f"/v2/apps/{app_id}/config"
+
+        # set an initial config value
+        value1 = {"name": "PORT", "value": "5000", "group": "global"}
+        body = {'values': [value1]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual([value1], response.data['values'])
+        url = f"/v2/apps/{app_id}/config"
+
+        # set config NAME
+        value2 = {"name": "NAME", "value": "drycc", "group": "global"}
+        body = {'values': [value2]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual([value1, value2], response.data['values'])
+
+        # set config WEBSITE
+        value3 = {"name": "WEBSITE", "value": "www.drycc.cc", "group": "global"}
+        body = {'values': [value3]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual([value1, value2, value3], response.data['values'])
+
+        url = f"/v2/apps/{app_id}/config/?version=v2"
+        response = self.client.get(url)
+        self.assertEqual(response.data['values'], [value1], response.data)
+
+        url = f"/v2/apps/{app_id}/config/?version=v3"
+        response = self.client.get(url)
+        self.assertEqual(
+            response.data['values'], [value1, value2], response.data)
+
+        url = f"/v2/apps/{app_id}/config/?version=v4"
+        response = self.client.get(url)
+        self.assertEqual(
+            response.data['values'],
+            [value1, value2, value3], response.data)
+
+    def test_diff_ptypes(self, mock_requests):
+        app_id = self.create_app()
+        value1 = {"name": "WEBSITE", "value": "www.drycc.cc", "group": "global"}
+        body = {'values': [value1]}
+        url = f"/v2/apps/{app_id}/config"
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        old_config = config.previous()
+        self.assertEqual(config.diff_ptypes(old_config, ["web", "task"]), {"web", "task"})
+
+        # add value
+        value1 = {"name": "DEBUG", "value": "true", "group": "mygroup"}
+        body = {'values': [value1]}
+        url = f"/v2/apps/{app_id}/config"
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        old_config = config.previous()
+        self.assertEqual(config.diff_ptypes(old_config, ["web", "task"]), set())
+
+        # add group ref
+        body = {'values_refs': {"task": ["mygroup"]}}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        old_config = config.previous()
+        self.assertEqual(config.diff_ptypes(old_config, ["web", "task"]), {"task"})
+        # add new group
+        value1 = {"name": "DEBUG", "value": "true", "group": "mygroup1"}
+        body = {'values': [value1]}
+        url = f"/v2/apps/{app_id}/config"
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertEqual(response.data['typed_values']['web'], {}, response.data)
-        # unset web
-        body = {'typed_values': {'web': None}}
+
+        body = {'values_refs': {"task": ["mygroup1"]}}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertEqual(response.data['typed_values'], {}, response.data)
+        uuid = response.data["uuid"]
+        config = Config.objects.get(uuid=uuid)
+        old_config = config.previous()
+        self.assertEqual(config.diff_ptypes(old_config, ["web", "task"]), {"task"})
+        self.assertEqual(config.envs("task"), {'WEBSITE': 'www.drycc.cc', 'DEBUG': 'true'})
+
+    def test_config_from_dryccfile(self, mock_requests):
+        app_id = self.create_app()
+        app = App.objects.get(id=app_id)
+        build_body = {
+            'image': 'autotest/example',
+            'stack': 'heroku-18',
+            'sha': 'a'*40,
+            'dryccfile': {
+                "pipeline": {
+                    "web.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "web",
+                        "build": {
+                            "docker": "Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "deploy": {
+                            'image': "127.0.0.1/cat/cat"
+                        },
+                    },
+                    "worker.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker",
+                        "build": {
+                            "docker": "worker/Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "deploy": {
+                            'image': "127.0.0.1/cat/cat"
+                        },
+                    },
+                },
+            },
+        }
+        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)
+            self.assertEqual(response.status_code, 201, response.data)
+        value1 = {"name": "WEBSITE", "value": "www.drycc.cc", "group": "global"}
+        body = {'values': [value1]}
+        url = f"/v2/apps/{app_id}/config"
+        response = self.client.post(url, body)
+        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"})
+        # set env by dryccfile
+        build_body['dryccfile']['config'] = {
+            "mygroup1": {
+                "GROUP": "g1",
+                "DEBUG": "tr",
+            },
+            "mygroup2": {
+                "TEST1": "g1",
+                "TEST2": "tr",
+            },
+        }
+        build_body['dryccfile']['pipeline']['web.yaml']['env'] = {
+            "PENV1": "web",
+            "PENV2": "web",
+        }
+        build_body['dryccfile']['pipeline']['web.yaml']['config'] = [
+            "mygroup1", "mygroup2"
+        ]
+        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)
+            self.assertEqual(response.status_code, 201, response.data)
+
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        self.assertEqual(
+            release.config.envs("web"),
+            {
+                'GROUP': 'g1', 'DEBUG': 'tr', 'TEST1': 'g1',
+                'TEST2': 'tr', 'PENV1': 'web', 'PENV2': 'web'
+            },
+        )
+
+        # test use old config and healthcheck
+        new_build_body = copy.deepcopy(build_body)
+        del new_build_body['dryccfile']['config']
+        del new_build_body['dryccfile']['pipeline']['web.yaml']['env']
+        del new_build_body['dryccfile']['pipeline']['web.yaml']['config']
+        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, new_build_body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        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'] = {}
+        new_build_body['dryccfile']['pipeline']['web.yaml']['env'] = {}
+        new_build_body['dryccfile']['pipeline']['web.yaml']['config'] = []
+        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, new_build_body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        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 1faca2cfa..918eea36d 100644
--- a/rootfs/api/tests/test_domain.py
+++ b/rootfs/api/tests/test_domain.py
@@ -8,7 +8,7 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import PROCFILE_TYPE_WEB
+from api.models.base import PTYPE_WEB
 from api.models.domain import Domain
 from api.tests import DryccTestCase
 
@@ -39,16 +39,15 @@ def test_response_data(self):
 
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': 'test-domain.example.com', 'procfile_type': PROCFILE_TYPE_WEB}
+            {'domain': 'test-domain.example.com', 'ptype': PTYPE_WEB}
         )
         self.assertEqual(response.status_code, 201, response.data)
 
         for key in response.data:
             self.assertIn(
-                key, ['uuid', 'owner', 'created', 'updated', 'app', 'domain', 'procfile_type'])
+                key, ['uuid', 'created', 'updated', 'app', 'domain', 'ptype'])
 
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'domain': 'test-domain.example.com'
         }
@@ -61,7 +60,7 @@ def test_strip_dot(self):
         url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
 
         # Create
-        response = self.client.post(url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+        response = self.client.post(url, {'domain': domain, 'ptype': PTYPE_WEB})
         self.assertEqual(response.status_code, 201, msg)
 
         # Fetch
@@ -99,7 +98,7 @@ def test_manage_idn_domain(self):
 
             # Create
             response = self.client.post(
-                url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                url, {'domain': domain, 'ptype': PTYPE_WEB})
             self.assertEqual(response.status_code, 201, msg)
 
             # Fetch
@@ -111,13 +110,13 @@ def test_manage_idn_domain(self):
             # Verify creation failure for same domain with different encoding
             if ace_domain != domain:
                 response = self.client.post(
-                    url, {'domain': ace_domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                    url, {'domain': ace_domain, 'ptype': PTYPE_WEB})
                 self.assertEqual(response.status_code, 400, msg)
 
             # Verify creation failure for same domain with different encoding
             if unicode_domain != domain:
                 response = self.client.post(
-                    url, {'domain': unicode_domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                    url, {'domain': unicode_domain, 'ptype': PTYPE_WEB})
                 self.assertEqual(response.status_code, 400, msg)
 
             # Delete
@@ -139,7 +138,7 @@ def test_manage_idn_domain(self):
             if ace_domain != domain:
                 # Create
                 response = self.client.post(
-                    url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                    url, {'domain': domain, 'ptype': PTYPE_WEB})
                 self.assertEqual(response.status_code, 201, msg)
 
                 # Fetch
@@ -167,7 +166,7 @@ def test_manage_idn_domain(self):
             if unicode_domain != domain:
                 # Create
                 response = self.client.post(
-                    url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                    url, {'domain': domain, 'ptype': PTYPE_WEB})
                 self.assertEqual(response.status_code, 201, msg)
 
                 # Fetch
@@ -213,7 +212,7 @@ def test_manage_domain(self):
 
             # Create
             response = self.client.post(
-                url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                url, {'domain': domain, 'ptype': PTYPE_WEB})
             self.assertEqual(response.status_code, 201, msg)
 
             # Fetch
@@ -253,7 +252,7 @@ def test_delete_domain_does_not_remove_latest(self):
         ]
         for domain in test_domains:
             response = self.client.post(
-                url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                url, {'domain': domain, 'ptype': PTYPE_WEB})
             self.assertEqual(response.status_code, 201, response.data)
 
         url = '/v2/apps/{app_id}/domains/{domain}'.format(domain=test_domains[0],
@@ -272,7 +271,7 @@ def test_manage_domain_invalid_app(self):
         # Create domain
         url = '/v2/apps/{app_id}/domains'.format(app_id="this-app-does-not-exist")
         response = self.client.post(
-            url, {'domain': 'test-domain.example.com', 'procfile_type': PROCFILE_TYPE_WEB})
+            url, {'domain': 'test-domain.example.com', 'ptype': PTYPE_WEB})
         self.assertEqual(response.status_code, 404)
 
         # verify
@@ -295,7 +294,7 @@ def test_manage_domain_invalid_domain(self):
         for domain in test_domains:
             msg = "failed on \"{}\"".format(domain)
             response = self.client.post(
-                url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                url, {'domain': domain, 'ptype': PTYPE_WEB})
             self.assertEqual(response.status_code, 400, msg)
 
     def test_admin_can_add_domains_to_other_apps(self):
@@ -311,7 +310,7 @@ def test_admin_can_add_domains_to_other_apps(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         url = '/v2/apps/{}/domains'.format(app_id)
         response = self.client.post(
-            url, {'domain': 'example.drycc.example.com', 'procfile_type': PROCFILE_TYPE_WEB})
+            url, {'domain': 'example.drycc.example.com', 'ptype': PTYPE_WEB})
         self.assertEqual(response.status_code, 201, response.data)
 
     def test_unauthorized_user_cannot_modify_domain(self):
@@ -329,7 +328,7 @@ def test_unauthorized_user_cannot_modify_domain(self):
 
         url = '/v2/apps/{}/domains'.format(app_id)
         response = self.client.post(
-            url, {'domain': 'example.com', 'procfile_type': PROCFILE_TYPE_WEB})
+            url, {'domain': 'example.com', 'ptype': PTYPE_WEB})
         self.assertEqual(response.status_code, 403)
 
     def test_kubernetes_service_failure(self):
@@ -343,5 +342,5 @@ def test_kubernetes_service_failure(self):
             domain = 'foo.com'
             url = '/v2/apps/{}/domains'.format(app_id)
             response = self.client.post(
-                url, {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB})
+                url, {'domain': domain, 'ptype': PTYPE_WEB})
             self.assertEqual(response.status_code, 201, response.data)
diff --git a/rootfs/api/tests/test_event.py b/rootfs/api/tests/test_event.py
new file mode 100644
index 000000000..2b3b0d717
--- /dev/null
+++ b/rootfs/api/tests/test_event.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests for the Drycc api app.
+
+Run the tests with "./manage.py test api"
+"""
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+
+from api.models.app import App
+
+from api.tests import adapter, DryccTransactionTestCase
+import requests_mock
+
+User = get_user_model()
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class EventTest(DryccTransactionTestCase):
+    """Tests setting and updating config values"""
+
+    fixtures = ['tests.json']
+
+    def setUp(self):
+        self.user = User.objects.get(username='autotest')
+        self.token = self.get_or_create_token(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        self.app = App.objects.get(id=self.create_app())
+
+    def tearDown(self):
+        # make sure every test has a clean slate for k8s mocking
+        cache.clear()
+
+    def build_deploy(self, app_id):
+        # post a new build with procfile
+        url = "/v2/apps/{app_id}/build".format(app_id=app_id)
+        body = {
+            'image': 'autotest/example',
+            'sha': 'a'*40,
+            'stack': 'heroku-18',
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+    def test_events(self, mock_requests):
+        """
+        Test that config is auto-created for a new app and that
+        config can be updated using a PATCH
+        """
+        app_id = self.create_app()
+        self.build_deploy(app_id)
+
+        # list events of pod
+        url = f"/v2/apps/{app_id}/pods"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        pod_name = response.data['results'][0]["name"]
+        response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
+        url = f"/v2/apps/{app_id}/events"
+        response = self.client.get(url, {"pod_name": pod_name})
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # list events of deployment
+        url = f"/v2/apps/{app_id}/events"
+        response = self.client.get(url, {"ptype": f"{app_id}-web"})
+        self.assertEqual(response.status_code, 200, response.data)
diff --git a/rootfs/api/tests/test_gateway.py b/rootfs/api/tests/test_gateway.py
index 2b8cc0794..dda6e3119 100644
--- a/rootfs/api/tests/test_gateway.py
+++ b/rootfs/api/tests/test_gateway.py
@@ -5,7 +5,8 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.build import Build
 from api.models.gateway import Route
 from api.models.release import Release
@@ -32,7 +33,7 @@ def create_app_with_domain_and_deploy(self):
         app_id = self.create_app()
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': 'test-domain.example.com', 'procfile_type': PROCFILE_TYPE_WEB}
+            {'domain': 'test-domain.example.com', 'ptype': PTYPE_WEB}
         )
         self.assertEqual(response.status_code, 201, response.data)
         # check default gateway route
@@ -42,19 +43,17 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
         return app_id
 
     def change_certs_auto(self, app_id, enabled):
@@ -70,8 +69,29 @@ def create_gateway(self, app_id, name, port, protocol):
         )
         self.assertEqual(response.status_code, 201)
 
+    def create_gateway_name(self):
+        app_id = self.create_app()
+        name1 = 'gatway_'
+        response = self.client.post(
+            f'/v2/apps/{app_id}/gateways/',
+            {'name': name1, 'port': 80, 'protocol': 'HTTP'}
+        )
+        self.assertEqual(response.status_code, 400)
+        name2 = '-gatway'
+        response = self.client.post(
+            f'/v2/apps/{app_id}/gateways/',
+            {'name': name2, 'port': 80, 'protocol': 'HTTP'}
+        )
+        self.assertEqual(response.status_code, 400)
+        name3 = 'test.gatway'
+        response = self.client.post(
+            f'/v2/apps/{app_id}/gateways/',
+            {'name': name3, 'port': 80, 'protocol': 'HTTP'}
+        )
+        self.assertEqual(response.status_code, 400)
+
     def create_tls_domain(self, app_id):
-        cert_url = '/v2/certs'
+        cert_url = f'/v2/apps/{app_id}/certs'
         secret_name = ''.join(random.choice(string.ascii_lowercase) for _ in range(23))
         domain = 'autotest.example.com'
 
@@ -92,10 +112,9 @@ def create_tls_domain(self, app_id):
 
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': domain, 'procfile_type': PROCFILE_TYPE_WEB}
+            {'domain': domain, 'ptype': PTYPE_WEB}
         )
         self.assertEqual(response.status_code, 201)
-
         response = self.client.post(
             '{}/{}/domain/'.format(cert_url, secret_name),
             {'domain': domain}
@@ -131,7 +150,6 @@ def test_add_listener(self):
                 break
         expect = {
             "app": app_id,
-            "owner": "autotest",
             "name": gateway_name,
             "listeners": [
                 {
@@ -174,39 +192,15 @@ 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-%s-0' % port,
-                'port': port,
-                'hostname': domain,
+                'allowedRoutes': {'namespaces': {'from': 'All'}},
+                'name': 'tcp-443-1', 'port': 443, 'hostname': domain,
                 'protocol': 'TLS',
-                'tls': {
-                    'certificateRefs': [{
-                        'kind': 'Secret',
-                        'name': secret_name
-                    }]
-                }
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
-                },
-                'name': 'tcp-%s-0' % port,
-                'port': port,
-                'protocol': 'TLS'
+                'tls': {'certificateRefs': [
+                    {'kind': 'Secret', 'name': f"{secret_name}-certificate"}]}
             }],
-            'addresses': [{
-                'type': 'IPAddress',
-                'value': '172.22.108.207'
-            }]
+            'addresses': [{'type': 'IPAddress', 'value': '172.22.108.207'}]
         }]
         self.assertEqual(results, expect)
 
@@ -216,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': {
@@ -224,25 +217,16 @@ def test_add_https_listener(self):
                         'from': 'All'
                     }
                 },
-                'name': 'mix-%s-0' % port,
+                'name': 'mix-%s-1' % port,
                 'port': port,
                 'hostname': domain,
                 'protocol': 'HTTPS',
                 'tls': {
                     'certificateRefs': [{
                         'kind': 'Secret',
-                        'name': secret_name
+                        'name': f"{secret_name}-certificate"
                     }]
                 }
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
-                },
-                'name': 'mix-%s-0' % port,
-                'port': port,
-                'protocol': 'HTTPS'
             }],
             'addresses': [{
                 'type': 'IPAddress',
@@ -256,64 +240,32 @@ 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',
-            'listeners': [{
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
-                },
-                'name': 'tcp-%s-0' % port,
-                'port': port,
-                'hostname': domain,
-                'protocol': 'HTTP',
-                'tls': {
-                    'certificateRefs': [{
-                        'kind': 'Secret',
-                        'name': secret_name
-                    }]
-                }
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
+            'app': 'bingo-gateway', 'name': 'bingo-gateway',
+            'listeners': [
+                {
+                    'allowedRoutes': {'namespaces': {'from': 'All'}},
+                    'name': 'tcp-80-1',
+                    'port': 80,
+                    'hostname': domain,
+                    'protocol': 'HTTP'
                 },
-                'name': 'tcp-%s-0' % port,
-                'port': port,
-                'protocol': 'HTTP'
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
+                {
+                    'allowedRoutes': {'namespaces': {'from': 'All'}},
+                    'name': 'tcp-80-0',
+                    'port': 80,
+                    'protocol': 'HTTP'
                 },
-                'name': 'mix-443-0',
-                'port': 443,
-                'hostname': 'autotest.example.com',
-                'protocol': 'HTTPS',
-                'tls': {
-                    'certificateRefs': [{
-                        'kind': 'Secret',
-                        'name': secret_name
-                    }]
+                {
+                    'allowedRoutes': {'namespaces': {'from': 'All'}},
+                    'name': 'mix-443-1',
+                    'port': 443,
+                    'hostname': domain,
+                    'protocol': 'HTTPS',
+                    'tls': {'certificateRefs': [
+                        {'kind': 'Secret', 'name': f"{secret_name}-certificate"}]}
                 }
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
-                },
-                'name': 'mix-443-0',
-                'port': 443,
-                'protocol': 'HTTPS'
-            }],
-            'addresses': [{
-                'type': 'IPAddress',
-                'value': '172.22.108.207'
-            }]
+            ],
+            'addresses': [{'type': 'IPAddress', 'value': '172.22.108.207'}]
         }]
         self.assertEqual(results, expect)
 
@@ -324,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': {
@@ -356,9 +307,9 @@ def test_remove_domain(self):
     def test_remove_tls(self):
         app_id, domain, secret_name, _ = self.add_tls_listener("bingo-gateway", "HTTPS", 443)
         response = self.client.get('/v2/apps/{}/gateways/'.format(app_id))
-        self.assertEqual(len(response.data["results"][0]["listeners"]), 2)
+        self.assertEqual(len(response.data["results"][0]["listeners"]), 1)
         response = self.client.delete(
-            '{}/{}/domain/{}/'.format('/v2/certs', secret_name, domain)
+            '{}/{}/domain/{}/'.format(f'/v2/apps/{app_id}/certs', secret_name, domain)
         )
         self.assertEqual(response.status_code, 204)
         response = self.client.get('/v2/apps/{}/gateways/'.format(app_id))
@@ -373,7 +324,7 @@ def test_certs_auto_enabled(self):
             data)
         self.assertEqual(response.status_code, 201, response.data)
         response = self.client.get('/v2/apps/{}/gateways/'.format(app_id))
-        self.assertEqual(len(response.data["results"][0]["listeners"]), 2)
+        self.assertEqual(len(response.data["results"][0]["listeners"]), 1)
 
     def test_remove_listener(self):
         app_id = self.create_app()
@@ -393,14 +344,24 @@ def test_change_tls(self):
         app_id = self.create_app_with_domain_and_deploy()
         response = self.client.get('/v2/apps/{}/gateways/'.format(app_id))
         expect = [{
-            'allowedRoutes': {
-                'namespaces': {
-                    'from': 'All'
+            "allowedRoutes": {
+                "namespaces": {
+                    "from": "All"
+                }
+            },
+            "name": "tcp-80-1",
+            "port": 80,
+            "hostname": "test-domain.example.com",
+            "protocol": "HTTP"
+        }, {
+            "allowedRoutes": {
+                "namespaces": {
+                    "from": "All"
                 }
             },
-            'name': 'tcp-80-0',
-            'port': 80,
-            'protocol': 'HTTP'
+            "name": "tcp-80-0",
+            "port": 80,
+            "protocol": "HTTP"
         }]
         self.assertEqual(response.data["count"], 1, response.data)
         self.assertEqual(response.data["results"][0]["listeners"], expect, response.data)
@@ -410,77 +371,55 @@ def test_change_tls(self):
         self.assertEqual(response.data["count"], 1, response.data)
         expect = [{
             'app': app_id,
-            'owner': 'autotest',
             'name': app_id,
-            'listeners': [{
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
-                },
-                'name': 'tcp-80-0',
-                'port': 80,
-                'hostname': 'test-domain.example.com',
-                'protocol': 'HTTP',
-                'tls': {
-                    'certificateRefs': [{
-                        'kind': 'Secret',
-                        'name': '%s-auto-tls' % app_id
-                    }]
-                }
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
+            'listeners': [
+                {
+                    'allowedRoutes': {'namespaces': {'from': 'All'}},
+                    'name': 'tcp-80-1',
+                    'port': 80,
+                    'hostname': 'test-domain.example.com',
+                    'protocol': 'HTTP'
                 },
-                'name': 'tcp-80-0',
-                'port': 80,
-                'protocol': 'HTTP'
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
+                {
+                    'allowedRoutes': {'namespaces': {'from': 'All'}},
+                    'name': 'tcp-80-0',
+                    'port': 80,
+                    'protocol': 'HTTP'
                 },
-                'name': 'mix-443-0',
-                'port': 443,
-                'hostname': 'test-domain.example.com',
-                'protocol': 'HTTPS',
-                'tls': {
-                    'certificateRefs': [{
-                        'kind': 'Secret',
-                        'name': '%s-auto-tls' % app_id
-                    }]
+                {
+                    'allowedRoutes': {'namespaces': {'from': 'All'}},
+                    'name': 'mix-443-1',
+                    'port': 443,
+                    'hostname': 'test-domain.example.com',
+                    'protocol': 'HTTPS',
+                    'tls': {'certificateRefs': [{'kind': 'Secret', 'name': f'{app_id}-auto-tls'}]}
                 }
-            }, {
-                'allowedRoutes': {
-                    'namespaces': {
-                        'from': 'All'
-                    }
-                },
-                'name': 'mix-443-0',
-                'port': 443,
-                'protocol': 'HTTPS'
-            }],
-            'addresses': [{
-                'type': 'IPAddress',
-                'value': '172.22.108.207'
-            }]
+            ],
+            'addresses': [{'type': 'IPAddress', 'value': '172.22.108.207'}]
         }]
         self.assertEqual(response.data["results"], expect, response.data)
 
         self.change_certs_auto(app_id, False)
         response = self.client.get('/v2/apps/{}/gateways/'.format(app_id))
         expect = [{
-            'allowedRoutes': {
-                'namespaces': {
-                    'from': 'All'
+            "allowedRoutes": {
+                "namespaces": {
+                    "from": "All"
+                }
+            },
+            "name": "tcp-80-1",
+            "port": 80,
+            "hostname": "test-domain.example.com",
+            "protocol": "HTTP"
+        }, {
+            "allowedRoutes": {
+                "namespaces": {
+                    "from": "All"
                 }
             },
-            'name': 'tcp-80-0',
-            'port': 80,
-            'protocol': 'HTTP'
+            "name": "tcp-80-0",
+            "port": 80,
+            "protocol": "HTTP"
         }]
         self.assertEqual(response.data["count"], 1, response.data)
         self.assertEqual(response.json()["results"][0]["listeners"], expect, response.data)
@@ -489,7 +428,7 @@ def test_change_tls(self):
 class RouteTest(BaseGatewayTest):
 
     def create_route(self, app_id, route_name="test-route",
-                     procfile_type="task", kind="HTTPRoute"):
+                     ptype="task", kind="HTTPRoute"):
         # create service
         port = 5000
         response = self.client.post(
@@ -498,7 +437,7 @@ def create_route(self, app_id, route_name="test-route",
                 'port': port,
                 'protocol': 'TCP',
                 'target_port': port,
-                'procfile_type': procfile_type
+                'ptype': ptype
             }
         )
         self.assertEqual(response.status_code, 201, response.data)
@@ -506,14 +445,20 @@ def create_route(self, app_id, route_name="test-route",
         response = self.client.post(
             '/v2/apps/{}/routes/'.format(app_id),
             {
-                "port": 5000,
-                "procfile_type": procfile_type,
                 "kind": kind,
                 "name": route_name,
+                "rules": [{
+                    "backendRefs": [{
+                        "kind": "Service",
+                        "name": f"{app_id}-{ptype}",
+                        "port": 5000,
+                        "weight": 100,
+                    }],
+                }],
             }
         )
         self.assertEqual(response.status_code, 201, response.data)
-        return procfile_type, port, route_name
+        return ptype, port, route_name
 
     def test_create_route(self):
         app_id = self.create_app()
@@ -523,19 +468,58 @@ def test_create_route(self):
             '/v2/apps/{}/routes/'.format(app_id),
             {
                 "port": 5000,
-                "procfile_type": "no-exists",
+                "ptype": "no-exists",
                 "kind": "HTTPRoute",
                 "name": "test-route-1",
             }
         )
         self.assertEqual(response.status_code, 404)
+        response = self.client.get('/v2/apps/{}/routes/'.format(app_id))
+        self.assertEqual(response.data["count"], 1)
+        self.assertEqual(len(response.data["results"][0]["rules"]), 1)
+
+        # name regex format
+        name1 = "test-route_"
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "port": 5000,
+                "ptype": "no-exists",
+                "kind": "HTTPRoute",
+                "name": name1,
+            }
+        )
+        self.assertEqual(response.status_code, 400)
+        # name regex format
+        name2 = "-test-route"
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "port": 5000,
+                "ptype": "no-exists",
+                "kind": "HTTPRoute",
+                "name": name2,
+            }
+        )
+        self.assertEqual(response.status_code, 400)
+        name3 = "test.route"
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "port": 5000,
+                "ptype": "no-exists",
+                "kind": "HTTPRoute",
+                "name": name3,
+            }
+        )
+        self.assertEqual(response.status_code, 400)
 
     def test_hostnames(self):
         app_id = self.create_app()
-        procfile_type, port, route_name = self.create_route(app_id)
+        ptype, port, route_name = self.create_route(app_id)
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': "domain1.test.com", 'procfile_type': procfile_type}
+            {'domain': "domain1.test.com", 'ptype': ptype}
         )
         self.assertEqual(response.status_code, 201)
         route = Route.objects.get(app__id=app_id)
@@ -543,7 +527,7 @@ def test_hostnames(self):
 
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': "domain2.test.com", 'procfile_type': "web-1"}
+            {'domain': "domain2.test.com", 'ptype': "web-1"}
         )
         self.assertEqual(response.status_code, 201)
         route = Route.objects.get(app__id=app_id)
@@ -551,7 +535,7 @@ def test_hostnames(self):
 
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': "domain3.test.com", 'procfile_type': procfile_type}
+            {'domain': "domain3.test.com", 'ptype': ptype}
         )
         self.assertEqual(response.status_code, 201)
         route = Route.objects.get(app__id=app_id)
@@ -559,7 +543,7 @@ def test_hostnames(self):
 
     def test_route_attach(self):
         app_id = self.create_app()
-        procfile_type, port, route_name = self.create_route(app_id)
+        ptype, port, route_name = self.create_route(app_id)
         gateway_name_1 = 'bing-gateway-1'
         self.create_gateway(app_id, gateway_name_1, 5000, "HTTP")
         self.client.patch(
@@ -582,20 +566,21 @@ def test_route_attach(self):
         )
         response = self.client.get('/v2/apps/{}/routes/'.format(app_id))
         self.assertEqual(len(response.data["results"][0]["parent_refs"]), 2)
-        return procfile_type, app_id, gateway_name_1, gateway_name_2, port, route_name
+        return ptype, app_id, gateway_name_1, gateway_name_2, port, route_name
 
     def test_route_check_parent_ok(self):
         app_id = self.create_app()
         _, port, route_name = self.create_route(app_id, "myroute1", "test")
         gateway_name_1 = 'bing-gateway-1'
         self.create_gateway(app_id, gateway_name_1, 5000, "HTTP")
-        self.client.patch(
+        response = self.client.patch(
             '/v2/apps/{}/routes/{}/attach/'.format(app_id, route_name),
             {
                 "gateway": gateway_name_1,
                 "port": port
             }
         )
+        self.assertEqual(response.status_code, 204, response.data)
         response = self.client.get('/v2/apps/{}/routes/'.format(app_id))
         self.assertEqual(len(response.data["results"][0]["parent_refs"]), 1)
         # create other route
@@ -675,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):
@@ -699,71 +684,40 @@ def test_route_delete(self):
 
     def test_rule_get(self):
         app_id = self.create_app()
-        procfile_type, _, route_name = self.create_route(app_id)
+        ptype, _, route_name = self.create_route(app_id)
         response = self.client.get(
             '/v2/apps/{}/routes/{}/rules/'.format(app_id, route_name),
         )
-        expect = {
-            'canary': [{
-                'backendRefs': [{
-                    'kind': 'Service',
-                    'name': '%s-%s' % (app_id, procfile_type),
-                    'port': 5000,
-                    'weight': 100
-                }, {
-                    'kind': 'Service',
-                    'name': '%s-%s-canary' % (app_id, procfile_type),
-                    'port': 5000,
-                    'weight': 0
-                }]
-            }],
-            'stable': [{
-                'backendRefs': [{
-                    'kind': 'Service',
-                    'name': '%s-%s' % (app_id, procfile_type),
-                    'port': 5000,
-                    'weight': 100
-                }]
+        expect = [{
+            'backendRefs': [{
+                'kind': 'Service',
+                'name': '%s-%s' % (app_id, ptype),
+                'port': 5000,
+                'weight': 100
             }]
-        }
-        self.assertEqual(response.data["stable"], expect["stable"])
-        self.assertEqual(response.data["canary"], expect["canary"])
+        }]
+        self.assertEqual(response.data, expect)
 
     def test_schemas(self):
         for rule in os.listdir("{}/rules/".format(TEST_ROOT)):
             with open("{}/rules/{}".format(TEST_ROOT, rule)) as f:
                 data = f.read()
                 try:
-                    validate_json(data, RULES_SCHEMA)
+                    validate_json(json.loads(data), RULES_SCHEMA)
                 except Exception as e:
                     raise self.failureException("validate %s rule error: %s" % (rule, str(e)))
 
     def test_rule_set(self):
         app_id = self.create_app()
-        procfile_type, _, route_name = self.create_route(app_id)
-        expect = {
-            "canary": [{
-                "backendRefs": [{
-                    "kind": "Service",
-                    "name": "%s-%s" % (app_id, procfile_type),
-                    "port": 5000,
-                    "weight": 100
-                }, {
-                    "kind": "Service",
-                    "name": "%s-%s-canary" % (app_id, procfile_type),
-                    "port": 5000,
-                    "weight": 0
-                }]
-            }],
-            "stable": [{
-                "backendRefs": [{
-                    "kind": "Service",
-                    "name": "%s-%s" % (app_id, procfile_type),
-                    "port": 5000,
-                    "weight": 100
-                }]
+        ptype, _, route_name = self.create_route(app_id)
+        expect = [{
+            "backendRefs": [{
+                "kind": "Service",
+                "name": "%s-%s" % (app_id, ptype),
+                "port": 5000,
+                "weight": 100
             }]
-        }
+        }]
         response = self.client.put(
             '/v2/apps/{}/routes/{}/rules/'.format(app_id, route_name),
             json.dumps(expect),
@@ -773,29 +727,14 @@ def test_rule_set(self):
             '/v2/apps/{}/routes/{}/rules/'.format(app_id, route_name),
         )
         self.assertEqual(json.dumps(response.data), json.dumps(expect))
-        expect = {
-            "canary": [{
-                "backendRefs": [{
-                    "kind": "Service",
-                    "name": "%s-%s-noexits" % (app_id, procfile_type),
-                    "port": 5000,
-                    "weight": 100
-                }, {
-                    "kind": "Service",
-                    "name": "%s-%s-noexits-canary" % (app_id, procfile_type),
-                    "port": 5000,
-                    "weight": 0
-                }]
-            }],
-            "stable": [{
-                "backendRefs": [{
-                    "kind": "Service",
-                    "name": "%s-%s-noexits" % (app_id, procfile_type),
-                    "port": 5000,
-                    "weight": 100
-                }]
+        expect = [{
+            "backendRefs": [{
+                "kind": "Service",
+                "name": "%s-%s-noexits" % (app_id, ptype),
+                "port": 5000,
+                "weight": 100
             }]
-        }
+        }]
         response = self.client.put(
             '/v2/apps/{}/routes/{}/rules/'.format(app_id, route_name),
             json.dumps(expect),
@@ -835,3 +774,83 @@ def test_change_tls(self):
         }]
         self.assertEqual(len(response.data["results"]), 1, response.data)
         self.assertEqual(response.data["results"][0]["parent_refs"], expect, response.data)
+
+    def test_route_parent_refs(self):
+        app_id = self.create_app_with_domain_and_deploy()
+        response = self.client.get('/v2/apps/{}/routes/'.format(app_id))
+        self.assertEqual(len(response.data["results"]), 1, response.data)
+        expect = [{
+            'name': f'{app_id}',
+            'port': 80
+        }]
+        # enable tls
+        self.change_certs_auto(app_id, True)
+        self.assertEqual(response.data["results"][0]["parent_refs"], expect, response.data)
+        response = self.client.get('/v2/apps/{}/routes/'.format(app_id))
+        self.assertEqual(len(response.data["results"]), 1, response.data)
+        expect = [{
+            'name': f'{app_id}',
+            'port': 80
+        }, {
+            'name': f'{app_id}',
+            'port': 443
+        }]
+        self.assertEqual(len(response.data["results"]), 1, response.data)
+        self.assertEqual(response.data["results"][0]["parent_refs"], expect, response.data)
+        # add new domain
+        ptype, kind, route_name, domain = (
+            'gateway', "HTTPRoute", "myroute-1", 'test-domain-gateway.example.com')
+        response = self.client.post(
+            '/v2/apps/{}/domains'.format(app_id),
+            {'domain': domain, 'ptype': ptype}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        # add new services
+        response = self.client.post(
+            '/v2/apps/{}/services'.format(app_id),
+            {
+                'port': 9999,
+                'protocol': 'TCP',
+                'target_port': 9999,
+                'ptype': 'gateway'
+            }
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        # add new router
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "kind": kind,
+                "name": route_name,
+                "rules": [{
+                    "backendRefs": [{
+                        "kind": "Service",
+                        "name": f"{app_id}-{ptype}",
+                        "port": 9999,
+                        "weight": 100,
+                    }],
+                }],
+            }
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.client.patch(
+            '/v2/apps/{}/routes/{}/attach/'.format(app_id, route_name),
+            {
+                "gateway": app_id,
+                "port": 443
+            }
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        route = Route.objects.get(name=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)
+        listeners = [
+            listener for listener in response.json()['spec']['listeners']
+            if listener['port'] == 443
+        ]
+        self.assertEqual(len(listeners) > 1,  True)
+        hostnames = [listener['hostname'] for listener in listeners]
+        self.assertEqual(domain in hostnames,  True)
diff --git a/rootfs/api/tests/test_healthchecks.py b/rootfs/api/tests/test_healthchecks.py
index 06fc730e5..b4e9db8a5 100644
--- a/rootfs/api/tests/test_healthchecks.py
+++ b/rootfs/api/tests/test_healthchecks.py
@@ -91,7 +91,7 @@ def test_config_healthchecks(self, mock_requests):
 
         # post a new build
         response = self.client.post(
-            f"/v2/apps/{app_id}/builds",
+            f"/v2/apps/{app_id}/build",
             {'image': 'quay.io/autotest/example', 'stack': 'container'}
         )
         self.assertEqual(response.status_code, 201, response.data)
diff --git a/rootfs/api/tests/test_hooks.py b/rootfs/api/tests/test_hooks.py
index 389ec6e6d..970ec5b5d 100644
--- a/rootfs/api/tests/test_hooks.py
+++ b/rootfs/api/tests/test_hooks.py
@@ -69,12 +69,6 @@ def test_key_hook(self, mock_requests):
         # Create app to use
         app_id = self.create_app()
 
-        # give user permission to app
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': str(self.user)}
-        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)
@@ -106,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'},
@@ -122,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'},
@@ -139,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),
@@ -150,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):
@@ -170,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'])
@@ -191,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'])
@@ -213,24 +207,23 @@ 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'])
 
         # make sure build fields were populated
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertIn('results', response.data)
-        build = response.data['results'][0]
+        build = response.data
         self.assertEqual(build['sha'], SHA)
         self.assertEqual(build['procfile'], PROCFILE)
 
         # test listing/retrieving container info
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', 'v2')
@@ -255,22 +248,21 @@ 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'])
         # make sure build fields were populated
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertIn('results', response.data)
-        build = response.data['results'][0]
+        build = response.data
         self.assertEqual(build['sha'], SHA)
         self.assertEqual(build['dockerfile'], DOCKERFILE)
         # test default container
-        url = f"/v2/apps/{app_id}/pods/web"
+        url = f"/v2/apps/{app_id}/pods/"
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, 'web', 'v2')
@@ -296,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'])
@@ -325,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_lock.py b/rootfs/api/tests/test_lock.py
new file mode 100644
index 000000000..146c8f037
--- /dev/null
+++ b/rootfs/api/tests/test_lock.py
@@ -0,0 +1,43 @@
+import time
+from api.tests import DryccTestCase
+from api.utils import CacheLock, DeployLock
+
+
+class TestLock(DryccTestCase):
+
+    def test_cache_lock(self):
+        key = f"test_key_1_{int(time.time())}"
+        lock1 = CacheLock(key)
+        lock2 = CacheLock(key)
+        timeout = 5
+        self.assertEqual(lock1.acquire(True, timeout), True)
+        self.assertEqual(lock2.acquire(False, timeout), False)
+        time.sleep(5)
+        start_time = time.time()
+        self.assertEqual(lock2.acquire(False, timeout), True)
+        self.assertEqual(lock1.acquire(True, timeout + 1), True)
+        self.assertTrue(time.time()-start_time > 5)
+        self.assertEqual(lock2.acquire(False, timeout), False)
+        lock1.release()
+        self.assertEqual(lock2.acquire(False, timeout), True)
+
+    def test_deploy_lock(self):
+        app_id = f"test_key_1_{int(time.time())}"
+        lock1 = DeployLock(app_id)
+        lock2 = DeployLock(app_id)
+        self.assertEqual(lock2.acquire(["web", "task"]), True)
+        self.assertEqual(lock1.acquire(["web"]), False)
+        self.assertEqual(lock1.acquire(["bing"]), True)
+        lock2.release(["web", "task"])
+        self.assertEqual(lock1.acquire(["web"]), True)
+        self.assertEqual(lock1.acquire(["task"]), True)
+        self.assertEqual(lock2.acquire(["web", "task"]), False)
+        lock2.release(["web", "task", "bing"])
+        self.assertEqual(lock1.acquire(["web", "task"]), True)
+        self.assertEqual(lock2.acquire(["web", "task"]), False)
+        self.assertEqual(lock2.acquire(["web", "bing"], True), True)
+        self.assertEqual(lock1.acquire(["web", "bing", "task"]), False)
+        self.assertEqual(lock1.acquire(["web", "bing", "task"], True), True)
+        lock2.release(["web", "bing", "task"])
+        self.assertEqual(lock1.acquire(["web"], True), True)
+        lock1.release(["web", "bing", "task"])
diff --git a/rootfs/api/tests/test_perm.py b/rootfs/api/tests/test_perm.py
index 2969859c0..96ec20e7a 100644
--- a/rootfs/api/tests/test_perm.py
+++ b/rootfs/api/tests/test_perm.py
@@ -1,212 +1,191 @@
 from django.test import tag
 from django.contrib.auth import get_user_model
+
 from api.tests import DryccTestCase
+from api.models.workspace import WorkspaceMember
+from api.models.app import App
+
 
 User = get_user_model()
 
 
-class TestAppPerms(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)
-
-        # TODO: test that git pushing to the app fails
-        # give user 2 permission to user 1's app
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        response = self.client.post(url, body)
+    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 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)
-
-        # 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']
-
-        # check that user 2 can't create a permission
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
+    def test_workspace_member_can_access_app(self):
+        ws_name, app_id = self._create_workspace_and_app()
+
+        # non-member cannot access app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.post(url, 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']
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
-        response = self.client.post(url, 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 = "/v2/apps/{}/perms/{}".format(app_id, 'autotest-2')
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 204, response.data)
-        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, 403)
+        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']
+    def test_admin_can_manage_workspace_members(self):
+        ws_name, _ = self._create_workspace_and_app(ws_name='testws03', app_id='testapp03')
 
-        # create a new object permission
-        url = "/v2/apps/{}/perms".format(app_id)
-        body = {'username': 'autotest-2'}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, 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'},
+        )
 
-        # list perms on the app
-        response = self.client.get("/v2/apps/{}/perms".format(app_id))
-        self.assertEqual(response.data, {'users': ['autotest-2']})
+        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(response.data['role'], 'viewer')
 
     @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.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
+    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.post(
+            f'/v2/apps/{app_id}/run',
+            {'command': 'echo hello'},
+        )
+        self.assertEqual(response.status_code, 404, response.data)
 
     @tag('auth')
-    def test_list_errors(self):
-        response = self.client.get('/v2/apps')
-        app_id = response.data['results'][0]['id']
+    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'},
+        )
 
-        # login as user 2, list perms on the app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get("/v2/apps/{}/perms".format(app_id))
-        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_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)
-
-        url = '{}/{}/perms'.format(url, app_id)
-        body = {'username': self.user2.username}
+    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'},
+        )
+
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 403)
+        response = self.client.patch(
+            f'/v2/workspaces/{ws_name}',
+            {'email': 'new@example.com'},
+        )
+        self.assertEqual(response.status_code, 403, response.data)
 
     @tag('auth')
-    def test_collaborator_cannot_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
-        url = '/v2/apps/{}/perms'.format(app_id)
-
-        # Share app with collaborator
-        body = {'username': collab.username}
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
-        response = self.client.post(url, body)
+    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)
 
-        # Collaborator should fail to share app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
-        body = {'username': self.user3.username}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 403)
+        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 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/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',
+        )
 
-        # 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_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 ' + collab_token)
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-
-        # Collaborator cannot delete other collaborator
-        url += "/{}".format(self.user3.username)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 403)
-
-        # Collaborator can delete themselves
-        url = '/v2/apps/{}/perms/{}'.format(app_id, collab.username)
-        response = self.client.delete(url)
+        response = self.client.patch(
+            f'/v2/apps/{app_id}',
+            {'workspace': 'testws10'},
+        )
         self.assertEqual(response.status_code, 204, 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 ba823fd6b..371b8208c 100644
--- a/rootfs/api/tests/test_pods.py
+++ b/rootfs/api/tests/test_pods.py
@@ -4,7 +4,6 @@
 Run the tests with "./manage.py test api"
 """
 import os
-import json
 
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
@@ -46,7 +45,7 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(len(response.data['results']), 0)
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -60,7 +59,7 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         # test setting one proc type at a time
         body = {'web': 4}
         response = self.client.post(url, body)
@@ -82,22 +81,8 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(response.data['structure']['web'], 4)
         self.assertEqual(response.data['structure']['worker'], 2)
 
-        # test listing/retrieving container info
-        url = f"/v2/apps/{app_id}/pods/web"
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['count'], 4)
-        self.assertEqual(len(response.data['results']), 4)
-
-        name = response.data['results'][0]['name']
-        url = f"/v2/apps/{app_id}/pods/web/{name}"
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['count'], 1)
-        self.assertEqual(response.data['results'][0]['name'], name)
-
         # scale down
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         # test setting two proc types at a time
         body = {'web': 2, 'worker': 1}
         response = self.client.post(url, body)
@@ -116,7 +101,7 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(response.data['structure']['worker'], 1)
 
         # scale down to 0
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 0, 'worker': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -140,7 +125,7 @@ def test_container_api(self, mock_requests):
         self.assertEqual(len(response.data['results']), 0)
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'container',
@@ -150,7 +135,7 @@ def test_container_api(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 6}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -164,14 +149,8 @@ def test_container_api(self, mock_requests):
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
 
-        # test listing/retrieving container info
-        url = f"/v2/apps/{app_id}/pods/web"
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 6)
-
         # scale down
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 3}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -186,7 +165,7 @@ def test_container_api(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
 
         # scale down to 0
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -210,7 +189,7 @@ def test_release(self, mock_requests):
         self.assertEqual(len(response.data['results']), 0)
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -224,7 +203,7 @@ def test_release(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -235,7 +214,7 @@ def test_release(self, mock_requests):
         self.assertPodContains(response.data['results'], app_id, "web", "v2")
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         # a web proctype must exist on the second build or else the container will be removed
         body = {
             'image': 'autotest/example',
@@ -255,7 +234,7 @@ def test_release(self, mock_requests):
 
         # post new config
         url = f"/v2/apps/{app_id}/config"
-        body = {'values': json.dumps({'KEY': 'value'})}
+        body = {'values': [{'name': 'KEY', 'value': 'value', 'group': 'global'}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
@@ -264,33 +243,50 @@ def test_release(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, "web", 'v4')
 
+        # describe pod
+        pod_name = response.data['results'][0]["name"]
+        response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # delete pods
+        response = self.client.delete(
+            f"/v2/apps/{app_id}/pods/",
+            {'pod_ids': pod_name}
+        )
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # describe no exists pod
+        pod_name = "no-exists-pod-name"
+        response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
+        self.assertEqual(response.status_code, 400, response.data)
+
     def test_container_errors(self, mock_requests):
         app_id = self.create_app()
 
         # create a release so we can scale
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
-        build = Build.objects.create(owner=user, app=app, image="qwerty")
+        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
         )
         # deploy
-        app.pipeline(release)
-        url = f"/v2/apps/{app_id}/scale"
+        release.deploy()
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 'not_an_int'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
+        app = App.objects.get(id=app_id)
         self.assertEqual(app.structure, {'web': 1})
 
         body = {'invalid': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
+        app = App.objects.get(id=app_id)
         self.assertEqual(app.structure, {'web': 1})
 
     def test_container_str(self, mock_requests):
@@ -298,7 +294,7 @@ def test_container_str(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -312,7 +308,7 @@ def test_container_str(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4, 'worker': 2}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -336,7 +332,7 @@ def test_pod_command_format(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -350,7 +346,7 @@ def test_pod_command_format(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -382,7 +378,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(len(response.data['results']), 0)
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -396,7 +392,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale to a negative number
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': -1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -404,7 +400,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(app.structure["web"], 1)
 
         # scale to something other than a number
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 'one'}
         response = self.client.post(url, body)
         app = App.objects.get(id=app_id)
@@ -412,7 +408,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(app.structure["web"], 1)
 
         # scale to something other than a number
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': [1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -420,7 +416,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(app.structure["web"], 1)
 
         # scale with a non-existent proc type
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'foo': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -428,7 +424,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual("foo" in app.structure, False)
 
         # scale up to an integer as a sanity check
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -437,7 +433,7 @@ def test_scale_errors(self, mock_requests):
 
         with mock.patch('scheduler.KubeHTTPClient.scale') as mock_kube:
             mock_kube.side_effect = KubeException('Boom!')
-            url = f"/v2/apps/{app_id}/scale"
+            url = f"/v2/apps/{app_id}/ptypes/scale"
             response = self.client.post(url, {'web': 10})
             self.assertEqual(response.status_code, 204, response.data)
             app = App.objects.get(id=app_id)
@@ -454,7 +450,7 @@ def test_admin_can_manage_other_pods(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -468,7 +464,7 @@ def test_admin_can_manage_other_pods(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # login as admin, scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4, 'worker': 2}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -476,11 +472,9 @@ 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}/scale'
+        url = f'/v2/apps/{app_id}/ptypes/scale'
         body = {'web': '1'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -491,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={
@@ -509,13 +501,12 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
 
         # use `start web` for backwards compatibility with buildpacks
         self.assertEqual(release.get_deploy_args('web'), [])
@@ -553,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",
@@ -568,13 +558,12 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
 
         # create a run pod
         url = f"/v2/apps/{app_id}/run"
@@ -615,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={
@@ -629,13 +617,12 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
 
         # create a run pod
         url = f"/v2/apps/{app_id}/run"
@@ -648,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={
@@ -666,13 +651,12 @@ 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
         )
         # deploy
-        app.pipeline(release)
+        release.deploy()
 
         # create a run pod
         url = f"/v2/apps/{app_id}/run"
@@ -681,7 +665,7 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests):
         self.assertEqual(response.status_code, 204, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 3}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -701,7 +685,7 @@ def test_scale_with_unauthorized_user_returns_403(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build
-        url = f"/v2/apps/{app_id}/builds"
+        url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -713,7 +697,7 @@ def test_scale_with_unauthorized_user_returns_403(self, mock_requests):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + unauthorized_token)
 
         # scale up with unauthorized user
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 403)
@@ -726,7 +710,7 @@ def test_modified_procfile_from_build_removes_pods(self, mock_requests):
         app_id = self.create_app()
 
         # post a new build
-        build_url = f"/v2/apps/{app_id}/builds"
+        build_url = f"/v2/apps/{app_id}/build"
         body = {
             'image': 'autotest/example',
             'stack': 'heroku-18',
@@ -738,7 +722,7 @@ def test_modified_procfile_from_build_removes_pods(self, mock_requests):
         }
         response = self.client.post(build_url, body)
 
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -753,51 +737,12 @@ def test_modified_procfile_from_build_removes_pods(self, mock_requests):
         }
         response = self.client.post(build_url, body)
         self.assertEqual(response.status_code, 201, response.data)
-
-        # make sure no pods are web
+        # check web pods
         application = App.objects.get(id=app_id)
         pods = application.list_pods(type='web')
         self.assertEqual(len(pods), 0)
-
-    def test_restart_pods(self, mock_requests):
-        app_id = self.create_app()
-
-        # post a new build
-        build_url = f"/v2/apps/{app_id}/builds"
-        body = {
-            'image': 'autotest/example',
-            'stack': 'heroku-18',
-            'sha': 'a'*40,
-            'procfile': {
-                'web': 'node server.js',
-                'worker': 'node worker.js'
-            }
-        }
-        response = self.client.post(build_url, body)
-
-        url = f"/v2/apps/{app_id}/scale"
-        body = {'web': 4, 'worker': 8}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 204, response.data)
-
-        # setup app object
-        application = App.objects.get(id=app_id)
-
-        # restart all pods
-        response = self.client.post('/v2/apps/{}/pods/restart'.format(app_id))
-        self.assertEqual(response.status_code, 204, response.data)
-
-        # restart only the workers
-        response = self.client.post('/v2/apps/{}/pods/worker/restart'.format(app_id))
-        self.assertEqual(response.status_code, 204, response.data)
-
-        # restart only the web
-        response = self.client.post('/v2/apps/{}/pods/web/restart'.format(app_id))
-        self.assertEqual(response.status_code, 204, response.data)
-
-        # restart only one of the web pods
-        pods = application.list_pods(type='web')
-        self.assertEqual(len(pods), 4)
+        pods = application.list_pods(type='worker')
+        self.assertEqual(len(pods), 0)
 
     def test_list_pods_failure(self, mock_requests):
         """
diff --git a/rootfs/api/tests/test_procfile.py b/rootfs/api/tests/test_procfile.py
new file mode 100644
index 000000000..cf4f3eb8b
--- /dev/null
+++ b/rootfs/api/tests/test_procfile.py
@@ -0,0 +1,94 @@
+from rest_framework.test import APITestCase
+from rest_framework import serializers
+from api.serializers import validate_ptype, BuildSerializer, ServiceSerializer
+
+
+class ProcfileTypeTest(APITestCase):
+
+    def assertException(self, method, exception=Exception, *args, **kwargs):
+        try:
+            method(*args, **kwargs)
+        except BaseException as e:
+            if not isinstance(e, exception):
+                raise AssertionError(
+                    "Did not throw the expected exception, "
+                    f"actual: {type(e)}, expect: {exception}"
+                )
+        else:
+            raise AssertionError(
+                f"Did not throw the expected exception, args: {args}, kwargs: {kwargs}")
+
+    def test_ptype_error(self):
+        for ptype in ["w", "we", "-a", "we-new-", "w" * 64]:
+            self.assertException(
+                validate_ptype, serializers.ValidationError, ptype)
+
+    def test_ptype_ok(self):
+        self.assertEqual(validate_ptype("web"), "web")
+        self.assertEqual(validate_ptype("w" * 63), "w" * 63)
+        self.assertEqual(validate_ptype("web-new-be"), "web-new-be")
+
+    def test_dryccfile_ptype(self):
+        dryccfile_1 = {
+            "pipeline": {
+                "web.yaml": {
+                    "kind": "pipeline",
+                    "ptype": "web",
+                    "build": {
+                        "docker": "Dockerfile",
+                    },
+                    "deploy": {
+                        "args": ["python", "-m", "http.server", "5000"],
+                    },
+                },
+                "worker.yaml": {
+                    "kind": "pipeline",
+                    "ptype": "worker",
+                    "build": {
+                        "docker": "Dockerfile.task",
+                    },
+                    "deploy": {
+                        "command": ["sleep"],
+                        "args": ["infinity"]
+                    },
+                }
+            }
+        }
+        validate_dryccfile = BuildSerializer().validate_dryccfile
+        self.assertEqual(validate_dryccfile(dryccfile_1), dryccfile_1)
+        dryccfile_1["pipeline"]["w.yaml"] = {
+            "kind": "pipeline",
+            "ptype": "w",
+            "build": {"docker": "Dockerfile.task"},
+        }
+        self.assertException(
+                validate_dryccfile, serializers.ValidationError, dryccfile_1)
+        del dryccfile_1["pipeline"]["w.yaml"]
+
+        dryccfile_1["pipeline"]["w.yaml"] = {
+            "kind": "pipeline",
+            "ptype": "w",
+            "build": {"docker": "Dockerfile.task"},
+            "deploy": {"args": ["python", "-m", "http.server", "5000"]},
+        }
+        self.assertException(
+                validate_dryccfile, serializers.ValidationError, dryccfile_1)
+        dryccfile_1["pipeline"]["w.yaml"]
+
+        dryccfile_1["pipeline"]["wnew.yaml"] = {
+            "kind": "pipeline",
+            "ptype": "w-new-",
+            "build": {"docker": "Dockerfile.task"},
+            "deploy": {"args": ["python", "-m", "http.server", "5000"]},
+        }
+        self.assertException(
+                validate_dryccfile, serializers.ValidationError, dryccfile_1)
+
+    def test_staticmethod_ptype(self):
+        s_validate_ptype = ServiceSerializer().validate_ptype
+        for ptype in ["w", "we", "-a", "we-new-", "w" * 64]:
+            self.assertException(
+                s_validate_ptype, serializers.ValidationError, ptype)
+        self.assertEqual(s_validate_ptype("web"), "web")
+        self.assertEqual(s_validate_ptype("w" * 63), "w" * 63)
+        self.assertEqual(s_validate_ptype("web-new-be"), "web-new-be")
diff --git a/rootfs/api/tests/test_ptypes.py b/rootfs/api/tests/test_ptypes.py
new file mode 100644
index 000000000..15a509bee
--- /dev/null
+++ b/rootfs/api/tests/test_ptypes.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests for the Drycc api app.
+
+Run the tests with "./manage.py test api"
+"""
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+
+from api.models.app import App
+
+from api.tests import adapter, DryccTransactionTestCase
+import requests_mock
+
+User = get_user_model()
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class PtypesTest(DryccTransactionTestCase):
+    """Tests setting and updating config values"""
+
+    fixtures = ['tests.json']
+
+    def setUp(self):
+        self.user = User.objects.get(username='autotest')
+        self.token = self.get_or_create_token(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        self.app = App.objects.get(id=self.create_app())
+
+    def tearDown(self):
+        # make sure every test has a clean slate for k8s mocking
+        cache.clear()
+
+    def build_deploy(self, app_id):
+        # post a new build with procfile
+        url = "/v2/apps/{app_id}/build".format(app_id=app_id)
+        body = {
+            'image': 'autotest/example',
+            'sha': 'a'*40,
+            'stack': 'heroku-18',
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+    def test_ptype(self, mock_requests):
+        """
+        Test that config is auto-created for a new app and that
+        config can be updated using a PATCH
+        """
+        app_id = self.create_app()
+        self.build_deploy(app_id)
+
+        # list ptypes deployment
+        url = f"/v2/apps/{app_id}/ptypes"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # describe ptype deployment
+        url = f"/v2/apps/{app_id}/ptypes/{app_id}-web/describe"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+
+        ptype = "no-exists-ptype-name"
+        response = self.client.get(f"/v2/apps/{app_id}/ptypes/{ptype}/describe/")
+        self.assertEqual(response.status_code, 400, response.data)
+
+    def test_restart_ptypes(self, mock_requests):
+        app_id = self.create_app()
+
+        # post a new build
+        build_url = f"/v2/apps/{app_id}/build"
+        body = {
+            'image': 'autotest/example',
+            'stack': 'heroku-18',
+            'sha': 'a'*40,
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(build_url, body)
+
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        body = {'web': 4, 'worker': 8}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # setup app object
+        application = App.objects.get(id=app_id)
+
+        # restart all ptypes deployments
+        response = self.client.post('/v2/apps/{}/ptypes/restart'.format(app_id))
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # restart web and workers ptype deployments
+        body = {"ptypes": "web,worker"}
+        response = self.client.post('/v2/apps/{}/ptypes/restart'.format(app_id), body)
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # restart invalid ptypes
+        body = {"ptypes": "web1"}
+        response = self.client.post('/v2/apps/{}/ptypes/restart'.format(app_id), body)
+        self.assertEqual(response.status_code, 400, response.data)
+
+        # restart only one of the web ptype deployments
+        pods = application.list_pods(type='web')
+        self.assertEqual(len(pods), 4)
diff --git a/rootfs/api/tests/test_registry.py b/rootfs/api/tests/test_registry.py
index 7a0ccbda3..0be570856 100644
--- a/rootfs/api/tests/test_registry.py
+++ b/rootfs/api/tests/test_registry.py
@@ -38,19 +38,19 @@ def test_registry(self, mock_requests):
         self.assertEqual(response.data['registry'], {})
 
         # set some registry information without PORT
-        body = {'registry': json.dumps({'username': 'bob'})}
+        body = {'registry': {'web': {'username': 'bob'}}}
         response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 400, response.data)
+        self.assertEqual(response.status_code, 201, response.data)
         registry1 = response.data
 
         # set required PORT
-        body = {'values': json.dumps({'PORT': '80'})}
+        body = {'values': [{"name": "PORT", "value": "80", "group": "global"}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         registry1 = response.data
 
-        # set some registry information
-        body = {'registry': json.dumps({'username': 'bob'})}
+        # no change error
+        body = {'registry': {'web': {'username': 'bobb'}}}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         registry1 = response.data
@@ -59,45 +59,45 @@ def test_registry(self, mock_requests):
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('registry', response.data)
-        registry = response.data['registry']
+        registry = response.data['registry']['web']
         self.assertIn('username', registry)
-        self.assertEqual(registry['username'], 'bob')
+        self.assertEqual(registry['username'], 'bobb')
 
         # set an additional value
-        # set them upper case, internally it should translate to lower
-        body = {'registry': json.dumps({'PASSWORD': 's3cur3pw1'})}
+        body = {'registry': {'web': {'password': 's3cur3pw1'}}}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         registry2 = response.data
         self.assertNotEqual(registry1['uuid'], registry2['uuid'])
-        registry = response.data['registry']
+        registry = response.data['registry']['web']
         self.assertIn('password', registry)
         self.assertEqual(registry['password'], 's3cur3pw1')
         self.assertIn('username', registry)
-        self.assertEqual(registry['username'], 'bob')
+        self.assertEqual(registry['username'], 'bobb')
 
         # read the registry information again
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         registry3 = response.data
         self.assertEqual(registry2, registry3)
-        registry = response.data['registry']
+        registry = response.data['registry']['web']
         self.assertIn('password', registry)
         self.assertEqual(registry['password'], 's3cur3pw1')
         self.assertIn('username', registry)
-        self.assertEqual(registry['username'], 'bob')
+        self.assertEqual(registry['username'], 'bobb')
 
         # unset a value
-        body = {'registry': json.dumps({'password': None})}
+        body = {'registry': {'web': {'password': None}}}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         registry4 = response.data
         self.assertNotEqual(registry3['uuid'], registry4['uuid'])
         self.assertNotIn('password', json.dumps(response.data['registry']))
 
-        # bad registry key values
-        body = {'registry': json.dumps({'pa$$w0rd': 'woop'})}
+        # key error
+        body = {'registry': {'web': {'pa$$w0rd': 'woop'}}}
         response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 400, response.data)
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
 
@@ -106,8 +106,6 @@ def test_registry(self, mock_requests):
         self.assertEqual(response.status_code, 405, response.data)
         response = self.client.patch(url)
         self.assertEqual(response.status_code, 405, response.data)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 405, response.data)
 
     def test_registry_deploy(self, mock_requests):
         """
@@ -118,20 +116,24 @@ def test_registry_deploy(self, mock_requests):
         # Set mandatory PORT
         response = self.client.post(
             f'/v2/apps/{app_id}/config',
-            {'values': json.dumps({'PORT': '4999'})}
+            {'values': [{"name": "PORT", "value": "4999", "group": "global"}]}
         )
 
         # Set registry information
-        body = {'registry': json.dumps({
-            'username': 'bob',
-            'password': 's3cur3pw1'
-        })}
+        body = {
+            'registry': {
+                'web': {
+                    'username': 'bob',
+                    'password': 's3cur3pw1'
+                }
+            }
+        }
         response = self.client.post(
             f'/v2/apps/{app_id}/config',
             body
         )
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('username', response.data['registry'])
-        self.assertIn('password', response.data['registry'])
-        self.assertEqual(response.data['registry']['username'], 'bob')
-        self.assertEqual(response.data['registry']['password'], 's3cur3pw1')
+        self.assertIn('username', response.data['registry']['web'])
+        self.assertIn('password', response.data['registry']['web'])
+        self.assertEqual(response.data['registry']['web']['username'], 'bob')
+        self.assertEqual(response.data['registry']['web']['password'], 's3cur3pw1')
diff --git a/rootfs/api/tests/test_release.py b/rootfs/api/tests/test_release.py
index f4f09b355..88ef6d6e1 100644
--- a/rootfs/api/tests/test_release.py
+++ b/rootfs/api/tests/test_release.py
@@ -10,10 +10,11 @@
 from django.test.utils import override_settings
 from unittest import mock
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
+from api.models.build import Build
 from api.models.release import Release
 from scheduler import KubeHTTPException
-from api.exceptions import DryccException
 from api.tests import adapter, DryccTransactionTestCase
 import requests_mock
 
@@ -44,10 +45,12 @@ def test_release(self, mock_requests):
         app_id = self.create_app()
         # check that updating config rolls a new release
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('NEW_URL1', response.data['values'])
+        self.assertEqual(body['values'], response.data['values'])
         # check to see that an initial release was created
         url = f'/v2/apps/{app_id}/releases'
         response = self.client.get(url)
@@ -71,7 +74,7 @@ def test_release(self, mock_requests):
         self.assertEqual(release1['build'], release2['build'])
         self.assertEqual(release2['version'], 2)
         # check that updating the build rolls a new release
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -104,51 +107,115 @@ def test_release(self, mock_requests):
         self.assertEqual(response.status_code, 405, response.content)
         return release3
 
+    def test_conditions(self, mock_requests):
+        app_id = self.create_app()
+        url = f"/v2/apps/{app_id}/build"
+        body = {
+            'image': 'autotest/example',
+            'stack': 'heroku-18',
+            'sha': 'a'*40,
+            'procfile': {
+                'web': 'node server.js',
+                'worker-test1': 'node worker.js'
+            }
+        }
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        url = f'/v2/apps/{app_id}/releases/'
+        response = self.client.get(url)
+        self.assertEqual(len(response.data['results'][0]['conditions']), 1)
+
+        with mock.patch('api.models.app.App.deploy') as mock_deploy:
+            mock_deploy.side_effect = Exception('Boom!')
+            url = f"/v2/apps/{app_id}/build"
+            response = self.client.post(url, body)
+            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'], 'Exception: Boom!')
+
     def test_get_image(self, mock_requests):
         app_id = self.create_app()
-        url = f"/v2/apps/{app_id}/builds"
+        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': {
-                "build": {
-                    "docker": {"web": "Dockerfile", "worker": "worker/Dockerfile"},
-                    "config": {"RAILS_ENV": "development", "FOO": "bar"}
-                },
-                "run": {
-                    "command": ["./deployment-tasks.sh"],
-                    "image": "worker",
-                },
-                "deploy": {
-                    "web": {
-                        "command": ["bash", "-c"],
-                        "args": ["bundle exec puma -C config/puma.rb"],
+                "pipeline": {
+                    "web.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "web",
+                        "build": {
+                            "docker": "Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "run": {"command": ["./deployment-tasks.sh"], "image": "worker"},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["bundle exec puma -C config/puma.rb"],
+                        },
                     },
-                    "worker": {
-                        "command": ["bash", "-c"],
-                        "args": ["python myworker.py"],
+                    "worker.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker",
+                        "build": {
+                            "docker": "worker/Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "run": {"command": ["./deployment-tasks.sh"], "image": "worker"},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["python myworker.py"],
+                        },
                     },
-                    "worker-1": {
-                        "image": "worker"
+                    "worker-1.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker-1",
+                        "run": {"command": ["./deployment-tasks.sh"], "image": "worker"},
+                        "deploy": {
+                            "image": "worker",
+                        },
                     },
-                    "worker-2": {},
-                    "worker-3": {
-                        "command": ["bash", "-c"],
-                        "args": ["bundle exec puma -C config/puma.rb"],
-                        "image": "web"
+                    "worker-2.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker-2",
+                        "run": {"command": ["./deployment-tasks.sh"], "image": "worker"},
+                        "deploy": {},
                     },
-                    "worker-4": {
-                        "command": ["bash", "-c"],
-                        "args": ["bundle exec puma -C config/puma.rb"],
-                        "image": "127.0.0.1:7070/myapp/web:git-123fsa1"
-                    }
-                }
-            }
+                    "worker-3.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker-3",
+                        "run": {"command": ["./deployment-tasks.sh"], "image": "worker"},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["bundle exec puma -C config/puma.rb"],
+                            "image": "web"
+                        },
+                    },
+                    "worker-4.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker-4",
+                        "run": {"command": ["./deployment-tasks.sh"], "image": "worker"},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["bundle exec puma -C config/puma.rb"],
+                            "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']
@@ -156,8 +223,10 @@ def test_get_image(self, mock_requests):
             self.assertEqual(response.status_code, 201, response.data)
             app = App.objects.get(id=app_id)
             release_obj = app.release_set.filter(version=2)[0]
+            runners = release_obj.get_runners(["web"])
+            self.assertEqual(len(runners), 1, release_obj.build.dryccfile)
             self.assertEqual(
-                release_obj.get_run_image(),
+                runners[0]["image"],
                 worker_image, release_obj.build.dryccfile)
             self.assertEqual(
                 release_obj.get_deploy_image("web"),
@@ -180,16 +249,18 @@ def test_get_image(self, mock_requests):
 
     def test_response_data(self, mock_requests):
         app_id = self.create_app()
-        body = {'values': json.dumps({'NEW_URL': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL", "value": "http://localhost:8080/", "group": "global"}]
+        }
         url = '/v2/apps/{}/config'.format(app_id)
         config_response = self.client.post(url, body)
         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',
-                                'summary', 'canary', 'version', 'state', 'failed', 'exception'])
+            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']),
@@ -198,6 +269,45 @@ def test_response_data(self, mock_requests):
         }
         self.assertEqual(response.data, response.data | expected)
 
+    def test_release_deploy(self, mock_requests):
+        app_id = self.create_app()
+        # try to rollback with only 1 release extant, expecting 400
+        url = f"/v2/apps/{app_id}/releases/deploy/"
+        # app.deploy
+        with mock.patch('api.models.app.App.deploy') as mock_deploy:
+            mock_deploy.return_value = None
+            response = self.client.post(url, {"ptypes": "web"})
+            self.assertEqual(response.status_code, 400)
+
+        # post a new build
+        build_url = f"/v2/apps/{app_id}/build"
+        body = {
+            'image': 'autotest/example',
+            'stack': 'heroku-18',
+            'sha': 'a'*40,
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(build_url, body)
+        self.assertEqual(response.status_code, 201)
+        # app.deploy
+        with mock.patch('api.models.app.App.deploy') as mock_deploy:
+            mock_deploy.return_value = None
+            response = self.client.post(url, {"ptypes": "web"})
+            self.assertEqual(response.status_code, 204)
+
+        url = f'/v2/apps/{app_id}/releases/?ptypes=web'
+        response = self.client.get(url)
+        self.assertEqual(response.data['count'], 1)
+        url = f'/v2/apps/{app_id}/releases/?ptypes=web,worker'
+        response = self.client.get(url)
+        self.assertEqual(response.data['count'], 1)
+        url = f'/v2/apps/{app_id}/releases/?ptypes=web,worker,noexists'
+        response = self.client.get(url)
+        self.assertEqual(response.data['count'], 0)
+
     def test_release_rollback(self, mock_requests):
         app_id = self.create_app()
         app = App.objects.get(id=app_id)
@@ -208,18 +318,19 @@ def test_release_rollback(self, mock_requests):
         self.assertEqual(response.data, {'detail': 'version cannot be below 0'})
         self.assertEqual(response.get('content-type'), 'application/json')
         # update the build to roll a new release
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         # update config to roll another release
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        values = [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        body = {'values': values}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         # create another release with a different build
-        url = f'/v2/apps/{app_id}/builds'
-        body = {'image': 'autotest/example:canary', 'stack': 'container'}
+        url = f'/v2/apps/{app_id}/build'
+        body = {'image': 'autotest/example:latest', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         # rollback and check to see that a 5th release was created
@@ -239,8 +350,8 @@ def test_release_rollback(self, mock_requests):
         self.assertEqual(release5.build, release3.build)
         self.assertEqual(release5.config.values, release3.config.values)
         # double-check to see that the current build and config is the same as v3
-        self.assertEqual(release5.get_deploy_image(PROCFILE_TYPE_WEB), 'autotest/example')
-        self.assertEqual(release5.config.values, {'NEW_URL1': 'http://localhost:8080/'})
+        self.assertEqual(release5.get_deploy_image(PTYPE_WEB), 'autotest/example')
+        self.assertEqual(release5.config.values, values)
         # try to rollback to v1 and verify that the rollback failed
         # (v1 is an initial release with no build)
         url = f"/v2/apps/{app_id}/releases/rollback/"
@@ -254,8 +365,8 @@ def test_release_rollback(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
         self.assertEqual(Release.objects.count(), 6)
         release6 = Release.objects.get(app=app, version=6)
-        self.assertEqual(release6.get_deploy_image(PROCFILE_TYPE_WEB), 'autotest/example')
-        self.assertEqual(release6.config.values, {})
+        self.assertEqual(release6.get_deploy_image(PTYPE_WEB), 'autotest/example')
+        self.assertEqual(release6.config.values, [])
 
     def test_release_str(self, mock_requests):
         """Test the text representation of a release."""
@@ -273,7 +384,7 @@ def test_release_summary(self, mock_requests):
         # add config, confirm that config objects are in the summary
         url = f'/v2/apps/{app.id}/config'
         body = {
-            'values': json.dumps({'FOO': 'bar'}),
+            'values': [{"name": "FOO", "value": "bar", "group": "global"}],
         }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -289,11 +400,13 @@ def test_admin_can_create_release(self, mock_requests):
         app_id = self.create_app()
         # check that updating config rolls a new release
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        }
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('NEW_URL1', response.data['values'])
+        self.assertEqual(body['values'], response.data['values'])
         # check to see that an initial release was created
         url = f'/v2/apps/{app_id}/releases'
         response = self.client.get(url)
@@ -311,13 +424,15 @@ def test_unauthorized_user_cannot_modify_release(self, mock_requests):
         app_id = self.create_app()
 
         # push a new build
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         body = {'image': 'test', 'stack': 'container'}
         response = self.client.post(url, body)
 
         # update config to roll a new release
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        }
         response = self.client.post(url, body)
         unauthorized_user = User.objects.get(username='autotest2')
         unauthorized_token = self.get_or_create_token(unauthorized_user)
@@ -335,7 +450,7 @@ def test_release_rollback_failure(self, mock_requests):
         app_id = self.create_app()
 
         # deploy app to get a build
-        url = "/v2/apps/{}/builds".format(app_id)
+        url = "/v2/apps/{}/build".format(app_id)
         body = {'image': 'autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -343,7 +458,9 @@ def test_release_rollback_failure(self, mock_requests):
 
         # update config to roll a new release
         url = '/v2/apps/{}/config'.format(app_id)
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
@@ -359,7 +476,9 @@ def test_release_rollback_failure(self, mock_requests):
 
         # update config to roll a new release
         url = '/v2/apps/{}/config'.format(app_id)
-        body = {'values': json.dumps({'NEW_URL2': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL2", "value": "http://localhost:8080/", "group": "global"}]
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
@@ -416,14 +535,18 @@ def test_release_no_change(self, mock_requests):
 
         # check that updating config rolls a new release
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        self.assertIn('NEW_URL1', response.data['values'])
+        self.assertEqual(body['values'], response.data['values'])
 
         # trigger identical release
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
+        body = {
+            'values': [{"name": "NEW_URL1", "value": "http://localhost:8080/", "group": "global"}]
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 409, response.data)
 
@@ -434,34 +557,56 @@ def test_release_get_port(self, mock_requests):
         app_id = self.create_app()
         app = App.objects.get(id=app_id)
 
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         body = {'sha': '123456', 'image': 'autotest/example', 'stack': 'heroku-18'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         release = app.release_set.latest()
 
         # when app is not routable, then it still return 5000
-        self.assertEqual(release.get_port(), 5000)
-
-        # when a buildpack type, default to 5000
-        self.assertEqual(release.get_port(), 5000)
+        self.assertEqual(release.get_port('web'), 5000)
 
         # switch to a dockerfile app or else it'll automatically default to 5000
-        url = f'/v2/apps/{app_id}/builds'
-        body = {'image': 'autotest/example', 'stack': 'container'}
+        url = f'/v2/apps/{app_id}/build'
+        body = {
+            'image': 'autotest/example',
+            'stack': 'container',
+            'procfile': {
+                'web': 'node server.js',
+                'task': 'node worker.js'
+            },
+        }
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
 
         url = f'/v2/apps/{app_id}/config'
-        body = {'values': json.dumps({'PORT': '8080'})}
+        body = {'values': [{"name": "PORT", "value": "8080", "group": "global"}]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         release = app.release_set.latest()
+        self.assertEqual(release.get_port('web'), 8080)
 
-        # check that the port number returned is an int, not a string
-        self.assertEqual(release.get_port(), 8080)
+        url = f'/v2/apps/{app_id}/config'
+        body = {'values': [{"name": "PORT", "value": "9000", "ptype": "web"}]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.get_port('web'), 9000)
 
-        # TODO(bacongobbler): test dockerfile ports
+        # not web procfile
+        self.assertEqual(release.get_port('task'), 8080)
+        url = f'/v2/apps/{app_id}/config'
+        body = {'values': [{"name": "PORT", "value": "9000", "group": "global"}]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.get_port('task'), 9000)
+        # set task port
+        body = {'values': [{"name": "PORT", "value": "9001", "ptype": "task"}]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.get_port('task'), 9001)
 
     @override_settings(DRYCC_DEPLOY_HOOK_URLS=['http://drycc.rocks'])
     @mock.patch('api.models.release.logger')
@@ -469,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
@@ -482,7 +627,7 @@ def test_deploy_hooks_logged(self, mock_requests, mock_logger):
 
         # override DRYCC_DEPLOY_HOOK_URLS again, ensuring that the new deploy hooks get the same
         # treatment
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         with self.settings(DRYCC_DEPLOY_HOOK_URLS=['http://drycc.ninja', 'http://cat.dog']):
             mr_ninja = mock_requests.post(f"http://drycc.ninja?app={app_id}&user={self.user.username}&sha=123456&release=v2&release_summary={self.user.username}+deployed+123456")  # noqa
             mr_catdog = mock_requests.post(f"http://cat.dog?app={app_id}&user={self.user.username}&sha=123456&release=v2&release_summary={self.user.username}+deployed+123456")  # noqa
@@ -563,52 +708,199 @@ def test_release_external_registry(self, mock_requests):
         app_id = self.create_app()
 
         # set the required port for external registries
-        body = {'values': json.dumps({'PORT': '3000'})}
+        body = {
+            'values': [{"name": "PORT", "value": "3000", "group": "global"}]
+        }
         config_response = self.client.post('/v2/apps/{}/config'.format(app_id), body)
         self.assertEqual(config_response.status_code, 201, config_response.data)
 
         app = App.objects.get(id=app_id)
-        url = f'/v2/apps/{app_id}/builds'
+        url = f'/v2/apps/{app_id}/build'
         body = {'image': 'test/autotest/example', 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         release = app.release_set.latest()
+        self.assertEqual(release.get_port('web'), 3000)
+        self.assertEqual(release.get_deploy_image(PTYPE_WEB), 'test/autotest/example')
 
-        self.assertEqual(release.get_port(), 3000)
-
-        self.assertEqual(release.get_deploy_image(PROCFILE_TYPE_WEB), 'test/autotest/example')
+        url = f'/v2/apps/{app_id}/config'
+        body = {'values': [{"name": "PORT", "value": "9000", "ptype": "web"}]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.get_port('web'), 9000)
 
-    @override_settings(REGISTRY_LOCATION="off-cluster")
-    def test_release_external_registry_no_port(self, mock_requests):
-        """
-        Test that an exception is raised when registry if off-cluster but
-        no port is provided.
-        """
+    @override_settings(DRYCC_PILELINE_RUN_TIMEOUT=600)
+    def test_run(self, mock_requests):
         app_id = self.create_app()
-
         app = App.objects.get(id=app_id)
-        url = f'/v2/apps/{app_id}/builds'
-        body = {'image': 'test/autotest/example', 'stack': 'container'}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
-        data = self.client.get(f"/v2/apps/{app_id}/releases/", body).json()
-        self.assertEqual(data["results"][0]["state"], "crashed", data)
-
-        with self.assertRaises(
-            DryccException,
-            msg='PORT needs to be set in the config when using a private registry'
-        ):
-            release = app.release_set.latest()
-            release.get_port()
-
-        # Set routable to false and port should be None instead of error
-        response = self.client.post(
-            f'/v2/apps/{app.id}/settings',
-            {'routable': False}
-        )
-        self.assertEqual(response.status_code, 201, response.data)
-        self.assertFalse(app.appsettings_set.latest().routable)
+        dryccfile = {
+            "pipeline": {
+                "web.yaml": {
+                    "kind": "pipeline",
+                    "ptype": "web",
+                    "run": {
+                        "args": ["sleep", "60s"],
+                        "image": "registry.drycc.cc/drycc/base:bookworm-web",
+                    },
+                    "deploy": {
+                        "image": "registry.drycc.cc/drycc/python-dev",
+                        "args": ["python", "-m", "http.server", "5000"]
+                    },
+                },
+                "task.yaml": {
+                    "kind": "pipeline",
+                    "ptype": "task",
+                    "run": {
+                        "args": ["sleep", "60s"],
+                        "image": "registry.drycc.cc/drycc/base:bookworm-task",
+                        "timeout": 3000
+                    },
+                    "deploy": {
+                        "image": "docker.io/library/nginx:mainline-bookworm-perl",
+                        "command": ["sleep"],
+                        "args": ["infinity"]
+                    },
+                },
+            },
+        }
+        build = Build.objects.create(
+            app=app, image="qwerty", procfile={},
+            sha='african-swallow', dockerfile={}, dryccfile=dryccfile)
+        release = Release.objects.create(
+            version=2, app=app, config=app.config_set.latest(),
+            build=build, state="succeed")
+        runners = release.get_runners(["web"])
+        self.assertEqual(len(runners), 1)
+        self.assertEqual(runners[0]["timeout"], 600)
+
+        runners = release.get_runners(["task"])
+        self.assertEqual(len(runners), 1)
+        self.assertEqual(runners[0]["timeout"], 3000)
+
+        # change timeout
+        dryccfile['pipeline']['web.yaml']['run']['timeout'] = 3600
+        dryccfile['pipeline']['web.yaml']['deploy'] = {
+            'image': 'registry.drycc.cc/drycc/python-dev:latest',
+        }
+        build = Build.objects.create(
+            app=app, image="qwerty", procfile={},
+            sha='african-swallow', dockerfile={}, dryccfile=dryccfile)
+        release = Release.objects.create(
+            version=3, app=app, config=app.config_set.latest(),
+            build=build, state="succeed")
+        runners = release.get_runners(["web"])
+        self.assertEqual(len(runners), 1)
+        self.assertEqual(runners[0]["timeout"], 3600)
+
+    def test_release_diff_ptypes(self, mock_requests):
+        app_id = self.create_app()
+        app = App.objects.get(id=app_id)
+        url = f"/v2/apps/{app_id}/build"
+        run_image = "127.0.0.1:7070/myapp/run:git-123fsa1"
+        web_image = "127.0.0.1:7070/myapp/web:git-123fsa1"
+        worker_image = "127.0.0.1:7070/myapp/worker:git-123fsa1"
+        body = {
+            'image': 'autotest/example',
+            'stack': 'heroku-18',
+            'sha': 'a'*40,
+            'dryccfile': {
+                "pipeline": {
+                    "web.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "web",
+                        "build": {
+                            "docker": "Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "run": {"command": ["./deployment-tasks.sh"], "image": run_image},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["bundle exec puma -C config/puma.rb"],
+                            "image": web_image
+                        },
+                    },
+                    "worker.yaml": {
+                        "kind": "pipeline",
+                        "ptype": "worker",
+                        "build": {
+                            "docker": "worker/Dockerfile",
+                            "arg": {
+                                "FOO": "bar",
+                                "RAILS_ENV": "development",
+                            },
+                        },
+                        "run": {"command": ["./deployment-tasks.sh"], "image": run_image},
+                        "deploy": {
+                            "command": ["bash", "-c"],
+                            "args": ["python myworker.py"],
+                            "image": worker_image
+                        },
+                    },
+                },
+            },
+        }
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        ptypes = release.diff_ptypes(release.ptypes)
+        self.assertEqual(ptypes, ["web", "worker"])
 
+        body['dryccfile']['config'] = {"g1": {"G1": "g1"}}
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        ptypes = release.diff_ptypes(release.ptypes)
+        self.assertEqual(ptypes, [])
+        # change worker env
+        body['dryccfile']['pipeline']['worker.yaml']['config'] = ["g1"]
+        body['dryccfile']['config'] = {"g1": {"G1": "g1"}}
+
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        ptypes = release.diff_ptypes(release.ptypes)
+        self.assertEqual(ptypes, ["worker"])
+        # env cover, only change web
+        body['dryccfile']['config'] = {"global": {"G1": "g1"}}
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        ptypes = release.diff_ptypes(release.ptypes)
+        self.assertEqual(ptypes, ["web"])
+        # add a global env
+        body['dryccfile']['config'] = {"global": {"G2": "g2"}}
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 201, response.data)
+        release = app.release_set.latest()
+        self.assertEqual(release.failed, False)
+        ptypes = release.diff_ptypes(release.ptypes)
+        self.assertEqual(set(ptypes), set(["web", "worker"]))
+
+        # change web env
+        body['dryccfile']['pipeline']['web.yaml']['env'] = {"ENV1": "env1"}
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 201, response.data)
         release = app.release_set.latest()
-        port = release.get_port()
-        self.assertIsNone(port)
+        self.assertEqual(release.failed, False)
+        ptypes = release.diff_ptypes(release.ptypes)
+        self.assertEqual(ptypes, ["web"])
diff --git a/rootfs/api/tests/test_resource.py b/rootfs/api/tests/test_resource.py
index 0ddb2551c..37af38ef9 100644
--- a/rootfs/api/tests/test_resource.py
+++ b/rootfs/api/tests/test_resource.py
@@ -6,7 +6,6 @@
 """
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
-from django.conf import settings
 from api.tests import adapter, DryccTransactionTestCase
 from api.exceptions import DryccException
 import requests_mock
@@ -27,8 +26,6 @@ def setUp(self):
         self.app_id = self.create_app()
 
     def tearDown(self):
-        # Restore default tags to empty string
-        settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
         # make sure every test has a clean slate for k8s mocking
         cache.clear()
 
@@ -36,6 +33,26 @@ def test_resources_create(self, mock_requests):
         """Test that the serialized response contains only relevant data."""
         app_id = self.create_app()
 
+        # name regex error
+        name1 = 'mysql_'
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': name1, 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name2 = '-mysql'
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': name2, 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name3 = 'mysql.8'
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': name3, 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+
         response = self.client.post(
             '/v2/apps/{}/resources'.format(app_id),
             data={'name': 'mysql', 'plan': 'mysql:5.6'}
@@ -44,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'
@@ -138,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(),
@@ -146,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 39e21fe3a..85f8f4b3a 100644
--- a/rootfs/api/tests/test_service.py
+++ b/rootfs/api/tests/test_service.py
@@ -1,8 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
-from django.conf import settings
 
-from api.models.app import App
 from api.tests import DryccTransactionTestCase
 from api.tests.test_gateway import RouteTest
 
@@ -42,7 +40,7 @@ def test_service_basic_ops(self):
                 'port': 5000,
                 'protocol': 'UDP',
                 'target_port': 5000,
-                'procfile_type': 'test'
+                'ptype': 'test'
             }
         )
         self.assertEqual(response.status_code, 201, response.data)
@@ -51,15 +49,14 @@ 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,
                 'protocol': 'UDP',
                 'targetPort': 5000,
             }],
-            "procfile_type": "test"
+            "ptype": "test"
         }
         self.assertEqual(response.data['services'][0], expected0 | response.data['services'][0])
         # port is occupied
@@ -69,15 +66,14 @@ def test_service_basic_ops(self):
                 'port': 5000,
                 'protocol': 'UDP',
                 'target_port': 5000,
-                'procfile_type': 'test'
+                'ptype': 'test'
             }
         )
         self.assertEqual(response.status_code, 400, response.data)
 
         # 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),
@@ -92,7 +88,7 @@ def test_service_basic_ops(self):
                     'targetPort': 6000,
                 }
             ],
-            "procfile_type": "test"
+            "ptype": "test"
         }
         response = self.client.post(
             '/v2/apps/{}/services'.format(app_id),
@@ -100,7 +96,7 @@ def test_service_basic_ops(self):
                 'port': 6000,
                 'protocol': 'TCP',
                 'target_port': 6000,
-                'procfile_type': 'test'
+                'ptype': 'test'
             }
         )
         self.assertEqual(response.status_code, 204, response.data)
@@ -116,7 +112,7 @@ def test_service_basic_ops(self):
                 'port': 5000,
                 'protocol': 'UDP',
                 'target_port': 5000,
-                'procfile_type': 'test2'
+                'ptype': 'test2'
             }
         )
         self.assertEqual(response.status_code, 201, response.data)
@@ -125,35 +121,34 @@ 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,
                 'protocol': 'UDP',
                 'targetPort': 5000,
             }],
-            "procfile_type": "test2"
+            "ptype": "test2"
         }
         self.assertEqual(response.data['services'][0], expected2 | response.data['services'][0])
         self.assertEqual(response.data['services'][1], expected1 | response.data['services'][1])
         # delete port
         response = self.client.delete(
             '/v2/apps/{}/services'.format(app_id),
-            {'procfile_type': 'test', "protocol": "TCP", "port": 6000}
+            {'ptype': 'test', "protocol": "TCP", "port": 6000}
         )
         response = self.client.get('/v2/apps/{}/services'.format(app_id))
         self.assertEqual(response.data['services'][1], expected0 | response.data['services'][1])
         # delete 1st
         response = self.client.delete(
             '/v2/apps/{}/services'.format(app_id),
-            {'procfile_type': 'test', "protocol": "UDP", "port": 5000}
+            {'ptype': 'test', "protocol": "UDP", "port": 5000}
         )
         self.assertEqual(response.status_code, 204, response.data)
         # delete 2nd
         response = self.client.delete(
             '/v2/apps/{}/services'.format(app_id),
-            {'procfile_type': 'test2',  "protocol": "UDP", "port": 5000}
+            {'ptype': 'test2',  "protocol": "UDP", "port": 5000}
         )
         self.assertEqual(response.status_code, 204, response.data)
         response = self.client.get('/v2/apps/{}/services'.format(app_id))
@@ -161,28 +156,6 @@ def test_service_basic_ops(self):
         # delete non-existing (1st again)
         response = self.client.delete(
             '/v2/apps/{}/services'.format(app_id),
-            {'procfile_type': 'test', "protocol": "UDP", "port": 5000}
+            {'ptype': 'test', "protocol": "UDP", "port": 5000}
         )
         self.assertEqual(response.status_code, 404, response.data)
-
-    def test_app_settings_change_canaries(self):
-        procfile_type, app_id, _, _, _, _ = self.test_route.test_route_attach()
-        # Add canaries
-        response = self.client.post(
-            f'/v2/apps/{app_id}/settings',
-            {'canaries': [procfile_type]}
-        )
-        self.assertEqual(response.status_code, 201, response.data)
-        app = App.objects.get(id=app_id)
-        response = app.scheduler().svc.get(app_id)
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(len(response.json()["items"]), 2)
-        # Remove canaries to false
-        response = self.client.delete(
-            f'/v2/apps/{app_id}/settings',
-            {'canaries': [procfile_type]}
-        )
-        self.assertEqual(response.status_code, 204, response.data)
-        response = app.scheduler().svc.get(app_id)
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(len(response.json()["items"]), 1)
diff --git a/rootfs/api/tests/test_tags.py b/rootfs/api/tests/test_tags.py
index 1913a56f5..dd6e81438 100644
--- a/rootfs/api/tests/test_tags.py
+++ b/rootfs/api/tests/test_tags.py
@@ -38,7 +38,7 @@ def test_tags(self, mock_requests):
         self.assertEqual(response.data['tags'], {})
 
         # set some tags
-        body = {'tags': json.dumps({'environ': 'dev'})}
+        body = {'tags': json.dumps({'web': {'environ': 'dev'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         tags1 = response.data
@@ -48,20 +48,19 @@ def test_tags(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('tags', response.data)
         tags = response.data['tags']
-        self.assertIn('environ', tags)
-        self.assertEqual(tags['environ'], 'dev')
+        self.assertIn('web', tags)
+        self.assertEqual(tags['web']['environ'], 'dev')
 
         # set an additional value
-        body = {'tags': json.dumps({'rack': '1'})}
+        body = {'tags': json.dumps({'web': {'rack': '1'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         tags2 = response.data
         self.assertNotEqual(tags1['uuid'], tags2['uuid'])
         tags = response.data['tags']
-        self.assertIn('rack', tags)
-        self.assertEqual(tags['rack'], '1')
-        self.assertIn('environ', tags)
-        self.assertEqual(tags['environ'], 'dev')
+        self.assertIn('web', tags)
+        self.assertEqual(tags['web']['rack'], '1')
+        self.assertEqual(tags['web']['environ'], 'dev')
 
         # read the limit again
         response = self.client.get(url)
@@ -69,13 +68,12 @@ def test_tags(self, mock_requests):
         tags3 = response.data
         self.assertEqual(tags2, tags3)
         tags = response.data['tags']
-        self.assertIn('rack', tags)
-        self.assertEqual(tags['rack'], '1')
-        self.assertIn('environ', tags)
-        self.assertEqual(tags['environ'], 'dev')
+        self.assertIn('web', tags)
+        self.assertEqual(tags['web']['rack'], '1')
+        self.assertEqual(tags['web']['environ'], 'dev')
 
         # unset a value
-        body = {'tags': json.dumps({'rack': None})}
+        body = {'tags': json.dumps({'web': {'rack': None}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         tags4 = response.data
@@ -83,16 +81,16 @@ def test_tags(self, mock_requests):
         self.assertNotIn('rack', json.dumps(response.data['tags']))
 
         # set valid values
-        body = {'tags': json.dumps({'kubernetes.io/hostname': '172.17.8.100'})}
+        body = {'tags': json.dumps({'web': {'kubernetes.io/hostname': '172.17.8.100'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        body = {'tags': json.dumps({'is.valid': 'is-also_valid'})}
+        body = {'tags': json.dumps({'web': {'is.valid': 'is-also_valid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        body = {'tags': json.dumps({'host.the-name.com/is.valid': 'valid'})}
+        body = {'tags': json.dumps({'web': {'host.the-name.com/is.valid': 'valid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
-        body = {'tags': json.dumps({'host.the-name.com/does.no.exist': 'valid'})}
+        body = {'tags': json.dumps({'web': {'host.the-name.com/does.no.exist': 'valid'}})}
         response = self.client.post(url, body)
         self.assertContains(
             response,
@@ -101,23 +99,23 @@ def test_tags(self, mock_requests):
         )
 
         # set invalid values
-        body = {'tags': json.dumps({'valid': 'in\nvalid'})}
+        body = {'tags': json.dumps({'web': {'valid': 'in\nvalid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
-        body = {'tags': json.dumps({'host.name.com/notvalid-': 'valid'})}
+        body = {'tags': json.dumps({'web': {'host.name.com/notvalid-': 'valid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
-        body = {'tags': json.dumps({'valid': 'invalid.'})}
+        body = {'tags': json.dumps({'web': {'valid': 'invalid.'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
-        body = {'tags': json.dumps({'host.name.com/,not.valid': 'valid'})}
+        body = {'tags': json.dumps({'web': {'host.name.com/,not.valid': 'valid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
         long_tag = 'a' * 300
-        body = {'tags': json.dumps({'{}/not.valid'.format(long_tag): 'valid'})}
+        body = {'tags': json.dumps({'web': {'{}/not.valid'.format(long_tag): 'valid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
-        body = {'tags': json.dumps({'this&foo.com/not.valid': 'valid'})}
+        body = {'tags': json.dumps({'web': {'this&foo.com/not.valid': 'valid'}})}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 400, response.data)
 
@@ -126,5 +124,3 @@ def test_tags(self, mock_requests):
         self.assertEqual(response.status_code, 405, response.data)
         response = self.client.patch(url)
         self.assertEqual(response.status_code, 405, response.data)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 405, response.data)
diff --git a/rootfs/api/tests/test_tls.py b/rootfs/api/tests/test_tls.py
index 8e595313c..3ca2baf0e 100644
--- a/rootfs/api/tests/test_tls.py
+++ b/rootfs/api/tests/test_tls.py
@@ -3,7 +3,8 @@
 from django.core.cache import cache
 from django.contrib.auth import get_user_model
 
-from api.models.app import App, PROCFILE_TYPE_WEB
+from api.models.app import App
+from api.models.base import PTYPE_WEB
 from api.models.tls import TLS
 from api.tests import adapter, DryccTransactionTestCase
 
@@ -75,7 +76,7 @@ def test_tls_events(self, mock_requests):
 
         response = self.client.post(
             '/v2/apps/{}/domains'.format(app_id),
-            {'domain': 'test-domain.example.com', 'procfile_type': PROCFILE_TYPE_WEB}
+            {'domain': 'test-domain.example.com', 'ptype': PTYPE_WEB}
         )
         self.assertEqual(response.status_code, 201, response.data)
         self.change_certs_auto(app_id, True)
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 b411bc3dc..20f908117 100644
--- a/rootfs/api/tests/test_volume.py
+++ b/rootfs/api/tests/test_volume.py
@@ -6,7 +6,6 @@
 """
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
-from django.conf import settings
 from api.tests import adapter, DryccTransactionTestCase
 import requests_mock
 
@@ -26,8 +25,6 @@ def setUp(self):
         self.app_id = self.create_app()
 
     def tearDown(self):
-        # Restore default tags to empty string
-        settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
         # make sure every test has a clean slate for k8s mocking
         cache.clear()
 
@@ -54,6 +51,38 @@ def test_volumecreate(self, mock_requests):
         )
         self.assertEqual(response.status_code, 400, response.data)
 
+        # name regex error
+        name1 = 'myvolume_'
+        response = self.client.post(
+            '/v2/apps/{}/volumes'.format(app_id),
+            data={
+                'name': name1,
+                'type': 'nfs',
+                'parameters': {'nfs': {'path': '/'}}
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name2 = '-myvolume'
+        response = self.client.post(
+            '/v2/apps/{}/volumes'.format(app_id),
+            data={
+                'name': name2,
+                'type': 'nfs',
+                'parameters': {'nfs': {'path': '/'}}
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name3 = 'my.volume'
+        response = self.client.post(
+            '/v2/apps/{}/volumes'.format(app_id),
+            data={
+                'name': name3,
+                'type': 'nfs',
+                'parameters': {'nfs': {'path': '/'}}
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+
         response = self.client.post(
             '/v2/apps/{}/volumes'.format(app_id),
             data={
@@ -64,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'
@@ -115,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'
@@ -132,7 +159,6 @@ def test_volume_expand(self, mock_requests):
                     'nfs': {
                         'server': 'test.drycc.cc',
                         'path': '/',
-                        'readOnly': False,
                     }
                 }
             }
@@ -178,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
@@ -231,7 +258,7 @@ def test_volume_unmount(self, mock_requests):
 
     def build_deploy(self, app_id):
         # post a new build with procfile
-        url = "/v2/apps/{app_id}/builds".format(app_id=app_id)
+        url = "/v2/apps/{app_id}/build".format(app_id=app_id)
         body = {
             'image': 'autotest/example',
             'sha': 'a'*40,
@@ -249,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(),
@@ -257,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 44d617444..c2dd93ebc 100644
--- a/rootfs/api/tests/test_workflow_manager.py
+++ b/rootfs/api/tests/test_workflow_manager.py
@@ -35,25 +35,23 @@ def setUp(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
 
     def tearDown(self):
-        # Restore default tags to empty string
-        settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
         # make sure every test has a clean slate for k8s mocking
         cache.clear()
 
     def test_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 ae83bd7b1..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/?$',
@@ -28,44 +50,61 @@
         views.LimitPlanViewSet.as_view({'get': 'retrieve'})),
     # application release components
     re_path(
-        r"^apps/(?P<id>{})/config/?$".format(settings.APP_URL_REGEX),
-        views.ConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
-    re_path(
-        r"^apps/(?P<id>{})/builds/(?P<uuid>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
-        views.BuildViewSet.as_view({'get': 'retrieve'})),
+        r"^apps/(?P<id>{})/build/?$".format(settings.APP_URL_REGEX),
+        views.BuildViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
     re_path(
-        r"^apps/(?P<id>{})/builds/?$".format(settings.APP_URL_REGEX),
-        views.BuildViewSet.as_view({'get': 'list', 'post': 'create'})),
+        r"^apps/(?P<id>{})/config/?$".format(settings.APP_URL_REGEX),
+        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'})),
+    re_path(
+        r"^apps/(?P<id>{})/releases/deploy/?$".format(settings.APP_URL_REGEX),
+        views.ReleaseViewSet.as_view({'post': 'deploy'})),
     re_path(
         r"^apps/(?P<id>{})/releases/rollback/?$".format(settings.APP_URL_REGEX),
         views.ReleaseViewSet.as_view({'post': 'rollback'})),
     re_path(
         r"^apps/(?P<id>{})/releases/?$".format(settings.APP_URL_REGEX),
         views.ReleaseViewSet.as_view({'get': 'list'})),
-    # restart pods
+    # list/delete pods
     re_path(
-        r"^apps/(?P<id>{})/pods/restart/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'post': 'restart'})),
+        r"^apps/(?P<id>{})/pods/?$".format(settings.APP_URL_REGEX),
+        views.PodViewSet.as_view({'get': 'list', 'delete': 'destroy'})),
+    # describe pod
     re_path(
-        r"^apps/(?P<id>{})/pods/(?P<type>[-_\w.]+)/restart/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'post': 'restart'})),
-    # list pods
+        r"^apps/(?P<id>{})/pods/(?P<name>{})/describe/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
+        views.PodViewSet.as_view({'get': 'describe'})),
+    # restart deployment/ptype's pods
     re_path(
-        r"^apps/(?P<id>{})/pods/(?P<type>[-_\w]+)/(?P<name>[-_\w]+)/?$".format(
-            settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'get': 'list'})),
+        r"^apps/(?P<id>{})/ptypes/restart/?$".format(settings.APP_URL_REGEX),
+        views.PtypeViewSet.as_view({'post': 'restart'})),
+    # clean old k8s resource
     re_path(
-        r"^apps/(?P<id>{})/pods/(?P<type>[-_\w.]+)/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'get': 'list'})),
+        r"^apps/(?P<id>{})/ptypes/clean/?$".format(settings.APP_URL_REGEX),
+        views.PtypeViewSet.as_view({'post': 'clean'})),
+    # scale ptype replcas
     re_path(
-        r"^apps/(?P<id>{})/pods/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'get': 'list'})),
+        r"^apps/(?P<id>{})/ptypes/scale/?$".format(settings.APP_URL_REGEX),
+        views.PtypeViewSet.as_view({'post': 'scale'})),
+    # list ptypes
+    re_path(
+        r"^apps/(?P<id>{})/ptypes/?$".format(settings.APP_URL_REGEX),
+        views.PtypeViewSet.as_view({'get': 'list'})),
+    # describe ptypes
+    re_path(
+        r"^apps/(?P<id>{})/ptypes/(?P<name>{})/describe/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
+        views.PtypeViewSet.as_view({'get': 'describe'})),
+    # list events
+    re_path(
+        r"^apps/(?P<id>{})/events/?$".format(settings.APP_URL_REGEX),
+        views.EventViewSet.as_view({'get': 'list'})),
     # application domains
     re_path(
-        r"^apps/(?P<id>{})/domains/(?P<domain>\**\.?[-\._\w]+)/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/domains/(?P<domain>{})/?$".format(
+            settings.APP_URL_REGEX, settings.DOMAIN_URL_REGEX),
         views.DomainViewSet.as_view({'delete': 'destroy'})),
     re_path(
         r"^apps/(?P<id>{})/domains/?$".format(settings.APP_URL_REGEX),
@@ -74,47 +113,27 @@
     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>{})/scale/?$".format(settings.APP_URL_REGEX),
-        views.AppViewSet.as_view({'post': 'scale'})),
-    re_path(
-        r"^apps/(?P<id>{})/run/?$".format(settings.APP_URL_REGEX),
-        views.AppViewSet.as_view({'post': 'run'})),
+                                     'get': 'list', 'delete': 'destroy'})),
     # application settings
     re_path(
         r"^apps/(?P<id>{})/settings/?$".format(settings.APP_URL_REGEX),
-        views.AppSettingsViewSet.as_view(
-            {'get': 'retrieve', 'post': 'create', 'delete': 'remove'})),
-    # application canary
-    re_path(
-        r"^apps/(?P<id>{})/canary/release/?$".format(settings.APP_URL_REGEX),
-        views.CanaryViewSet.as_view({'post': 'release'})),
-    re_path(
-        r"^apps/(?P<id>{})/canary/rollback/?$".format(settings.APP_URL_REGEX),
-        views.CanaryViewSet.as_view({'post': 'rollback'})),
+        views.AppSettingsViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
     # application TLS settings
     re_path(
         r"^apps/(?P<id>{})/tls/?$".format(settings.APP_URL_REGEX),
         views.TLSViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
-    # apps sharing
-    re_path(
-        r"^apps/(?P<id>{})/perms/(?P<username>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
-        views.AppPermsViewSet.as_view({'delete': 'destroy'})),
-    re_path(
-        r"^apps/(?P<id>{})/perms/?$".format(settings.APP_URL_REGEX),
-        views.AppPermsViewSet.as_view({'get': 'list', 'post': 'create'})),
     # application volumes
     re_path(
         r"^apps/(?P<id>{})/volumes/?$".format(settings.APP_URL_REGEX),
         views.AppVolumesViewSet.as_view({'get': 'list', 'post': 'create'})),
     re_path(
-        r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/volumes/(?P<name>{})/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppVolumesViewSet.as_view(
             {'get': 'retrieve', 'patch': 'expand', 'delete': 'destroy'})),
     re_path(
-        r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/path/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/volumes/(?P<name>{})/path/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppVolumesViewSet.as_view({'patch': 'path'})),
     # application resources
     re_path(r"^resources/services/?$", views.AppResourcesViewSet.as_view({'get': 'services'})),
@@ -125,16 +144,34 @@
         r"^apps/(?P<id>{})/resources/?$".format(settings.APP_URL_REGEX),
         views.AppResourcesViewSet.as_view({'get': 'list', 'post': 'create'})),
     re_path(
-        r"^apps/(?P<id>{})/resources/(?P<name>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/resources/(?P<name>{})/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppSingleResourceViewSet.as_view(
             {'get': 'retrieve', 'delete': 'destroy', 'put': 'update'})),
     re_path(
-        r"^apps/(?P<id>{})/resources/(?P<name>[-_\w]+)/binding/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/resources/(?P<name>{})/binding/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppResourceBindingViewSet.as_view({'patch': 'binding'})),
+    # certificates
+    re_path(
+        r'^apps/(?P<id>{})/certs/(?P<name>{})/domain/(?P<domain>{})?/?$'.format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX, settings.DOMAIN_URL_REGEX),
+        views.CertificateViewSet.as_view({'delete': 'detach', 'post': 'attach'})),
+    re_path(
+        r'^apps/(?P<id>{})/certs/(?P<name>{})/?$'.format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
+        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'})),
@@ -147,7 +184,7 @@
         views.KeyViewSet.as_view({'get': 'list', 'post': 'create'})),
     # hooks
     re_path(
-        r'^hooks/keys/(?P<id>{})/(?P<username>[-_\w]+)/?$'.format(settings.APP_URL_REGEX),
+        r'^hooks/keys/(?P<id>{})/(?P<username>[\w.@+-]+)/?$'.format(settings.APP_URL_REGEX),
         views.KeyHookViewSet.as_view({'get': 'users'})),
     re_path(
         r'^hooks/keys/(?P<id>{})/?$'.format(settings.APP_URL_REGEX),
@@ -164,59 +201,35 @@
     # authn / authz
     re_path(
         r'^auth/whoami/?$',
-        views.UserManagementViewSet.as_view({'get': 'list'})),
-    # admin sharing
-    re_path(
-        r'^admin/perms/(?P<username>[\w.@+-]+)/?$',
-        views.AdminPermsViewSet.as_view({'delete': 'destroy'})),
-    re_path(
-        r'^admin/perms/?$',
-        views.AdminPermsViewSet.as_view({'get': 'list', 'post': 'create'})),
-    # certificates
-    re_path(
-        r'^certs/(?P<name>[-_*.\w]+)/domain/(?P<domain>\**\.?[-\._\w]+)?/?$',
-        views.CertificateViewSet.as_view({'delete': 'detach', 'post': 'attach'})),
-    re_path(
-        r'^certs/(?P<name>[-_*.\w]+)/?$',
-        views.CertificateViewSet.as_view({
-            'get': 'retrieve',
-            'delete': 'destroy'
-        })),
-    re_path(
-        r'^certs/?$',
-        views.CertificateViewSet.as_view({'get': 'list', 'post': 'create'})),
+        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>[-_\w]+)?/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})?/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view(
-            {'post': 'create', 'get': 'list', 'delete': 'delete'})),
+            {'post': 'create', 'get': 'list', 'delete': 'destroy'})),
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)/attach/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})/attach/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view({'patch': 'attach'})),
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)/detach/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})/detach/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view({'patch': 'detach'})),
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)/rules/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})/rules/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view({'get': 'get', 'put': 'set'})),
-    # users
-    re_path(r'^users/?$', views.UserView.as_view({'get': 'list'})),
-    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'})),
     re_path(
-        r'^apps/(?P<id>{})/metrics/(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)/status/?$'.format(
+        r'^apps/(?P<id>{})/metrics/(?P<ptype>[a-z0-9]+(\-[a-z0-9]+)*)/status/?$'.format(
             settings.APP_URL_REGEX),
         views.MetricView.as_view({'get': 'status'})),
     re_path(
@@ -225,13 +238,22 @@
     re_path(
         r'^manager/(?P<type>[\w.@+-]+)s/(?P<id>{})/unblock/?$'.format(settings.APP_URL_REGEX),
         views.WorkflowManagerViewset.as_view({'delete': 'unblock'})),
+    # quickwit
+    re_path(
+        r'^quickwit/(?P<username>[\w.@+-]+)/(?P<path>.+)/?$', views.QuickwitProxyView.as_view()),
+    # prometheus
+    re_path(
+        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 = [
@@ -241,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 de4c63ff7..d2aa1d519 100644
--- a/rootfs/api/utils.py
+++ b/rootfs/api/utils.py
@@ -3,6 +3,8 @@
 """
 import os
 import re
+import uuid
+import time
 import base64
 import string
 import concurrent
@@ -13,32 +15,41 @@
 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
 from rest_framework.exceptions import ValidationError
 
+
 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():
@@ -61,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):
@@ -73,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.
@@ -154,8 +197,9 @@ def apply_tasks(tasks):
     if not tasks:
         return
 
-    executor = concurrent.futures.ThreadPoolExecutor(5)
-    for future in [executor.submit(task) for task in tasks]:
+    executor = concurrent.futures.ThreadPoolExecutor(settings.DRYCC_APPLY_TASKS)
+    for future, callback in [(executor.submit(task[0]), task[1]) for task in tasks]:
+        future.add_done_callback(callback)
         error = future.exception()
         if error is not None:
             raise error
@@ -187,7 +231,7 @@ def unit_to_bytes(size):
     return round(_)
 
 
-def validate_json(value, schema, raise_exception=ValidationError):
+def validate_json(value, schema, raise_exception=jsonschema.ValidationError):
     if value is not None:
         try:
             jsonschema.validate(value, schema)
@@ -196,6 +240,96 @@ def validate_json(value, schema, raise_exception=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):
+        self.key = key
+        self.value = uuid.uuid4().hex
+
+    def acquire(self, blocking=True, timeout=120):
+        value = None
+        for _ in range(timeout):
+            value = cache.get_or_set(self.key, self.value, timeout)
+            if not blocking or value == self.value:
+                break
+            time.sleep(1)
+        return value == self.value
+
+    def release(self):
+        value = cache.get(self.key, None)
+        if value != self.value:
+            return
+        cache.delete(self.key)
+
+
+class DeployLock(CacheLock):
+
+    def __init__(self, app_id):
+        self.ptypes_key = f"app:deploy:procfile:types:{app_id}"
+        super(DeployLock, self).__init__(f"app:deploy:lock:{app_id}")
+
+    def locked(self, ptypes):
+        value = cache.get(self.ptypes_key, [])
+        locked_ptypes = []
+        for ptype in ptypes:
+            if ptype in value:
+                locked_ptypes.append(ptype)
+        return locked_ptypes
+
+    def acquire(self, ptypes, force=False):
+        try:
+            if super(DeployLock, self).acquire():
+                value = cache.get(self.ptypes_key, [])
+                if not force and len(self.locked(ptypes)) > 0:
+                    return False
+                value.extend(ptypes)
+                value = list(set(value))
+                cache.set(self.ptypes_key, value, timeout=3600)
+                return True
+        finally:
+            super(DeployLock, self).release()
+        return False
+
+    def release(self, ptypes):
+        try:
+            if super(DeployLock, self).acquire():
+                value = cache.get(self.ptypes_key, [])
+                if value is None:
+                    return
+                for ptype in ptypes:
+                    if ptype in value:
+                        value.remove(ptype)
+                cache.set(self.ptypes_key, value)
+        finally:
+            super(DeployLock, self).release()
+
+
+class SyncIterToAsyncIter(object):
+
+    def __init__(self, iter_content):
+        self.iter_content = iter_content
+
+    def __aiter__(self):
+        return self
+
+    async def __anext__(self):
+        @sync_to_async
+        def async_next():
+            try:
+                return next(self.iter_content)
+            except StopIteration:
+                raise StopAsyncIteration
+        return await async_next()
+
+
+iter_to_aiter = SyncIterToAsyncIter
+
+
 if __name__ == "__main__":
     import doctest
     doctest.testmod()
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 7539f7ce0..131889d5a 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -5,42 +5,54 @@
 import uuid
 import logging
 import json
+import ssl
+import time
+import zlib
+import random
+import secrets
+import aiohttp
 import requests
+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, 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.exceptions import PermissionDenied, NotFound
+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.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
+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 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
-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
 
+
 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'
 
@@ -109,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,
             },
@@ -132,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)
 
@@ -163,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)
@@ -177,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)
@@ -200,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()
@@ -219,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.IsAppUser]
-    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):
@@ -263,69 +502,33 @@ 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):
         """Retrieve the object based on the latest release's value"""
-        return getattr(
-            self.get_app().release_set.filter(failed=False).latest(),
-            self.model.__name__.lower()
-        )
+        version = self.request.query_params.get('version', '').lower().strip('v')
+        if re.search("^[0-9]+$", version):
+            release = get_object_or_404(
+                models.release.Release, app=self.get_app(), version=int(version))
+        else:
+            release = models.release.Release.latest(self.get_app())
+        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, 'api.use_app')
-        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 scale(self, request, **kwargs):
-        app = self.get_object()
-        latest_release = app.release_set.filter(failed=False).latest()
-        if latest_release.build is not None and latest_release.state == "created":
-            raise DryccException('There is an executing pipeline, please wait')
-        scale_app.delay(self.get_object(), request.user, request.data)
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-    def logs(self, request, **kwargs):
-        app = self.get_object()
-        try:
-            logs = app.logs(request.query_params.get('log_lines', str(settings.LOG_LINES)))
-            return HttpResponse(logs, status=status.HTTP_200_OK, content_type='text/plain')
-        except NotFound:
-            return HttpResponse(status=status.HTTP_204_NO_CONTENT)
-        except ServiceUnavailable:
-            # TODO make 503
-            return HttpResponse("Error accessing logs for {}".format(app.id),
-                                status=status.HTTP_500_INTERNAL_SERVER_ERROR,
-                                content_type='text/plain')
-
     def run(self, request, **kwargs):
         app = self.get_object()
+        ptype = request.data.get('ptype', 'run')
         command = request.data.get('command', '').split()
         timeout = int(request.data.get('timeout', 3600))
         expires = int(request.data.get('expires', 3600))
@@ -333,25 +536,36 @@ def run(self, request, **kwargs):
             expires = settings.KUBERNETES_JOB_MAX_TTL_SECONDS_AFTER_FINISHED
         if not command:
             raise DryccException('command is a required field, or it can be defined in Procfile')
+        release = models.release.Release.latest(app)
+        if release is None or release.build is None:
+            raise DryccException('no build available, please deploy a release')
         volumes = request.data.get('volumes', None)
         if volumes:
             volumes = serializers.VolumeSerializer().validate_path(volumes)
-        app.run(self.request.user, args=command,
-                volumes=volumes, timeout=timeout, expires=expires)
+        app.run(self.request.user, release.get_deploy_image(ptype),
+                command=release.get_deploy_command(ptype), args=command, volumes=volumes,
+                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:
-                raise PermissionDenied()
-            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):
@@ -359,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
@@ -378,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
@@ -405,40 +623,62 @@ class ConfigViewSet(ReleasableViewSet):
     model = models.config.Config
     serializer_class = serializers.ConfigSerializer
 
+    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(), 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 = config.app.release_set.filter(failed=False).latest()
-        if latest_release.build is not None and latest_release.state == "created":
-            raise DryccException('There is an executing pipeline, please wait')
+        latest_release = models.release.Release.latest(self.get_app())
         try:
-            release = latest_release.new(
-                self.request.user, config=config, build=latest_release.build,
-                canary=latest_release.canary)
-            if release.build is not None:
-                procfile_types = set()
-                for field, diff in config.diff().items():
-                    if field in config.procfile_fields:
-                        for value in diff.values():
-                            procfile_types.update(value.keys())
-                config.app.deploy(
-                    release, procfile_types=list(procfile_types) if procfile_types else None)
-            release.state = "succeed"
-            release.save()
-        except Exception as e:
-            if 'release' in locals():
-                release.state = "crashed"
-                release.failed = True
-                release.summary = "{} deployed a config that failed".format(self.request.user)
-                # Get the exception that has occured
-                release.exception = "error: {}".format(str(e))
-                release.save()
-            else:
-                config.delete()
+            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:
+            config.delete()
             if isinstance(e, AlreadyExists):
                 raise
             raise DryccException(str(e)) from e
 
 
-class PodViewSet(AppResourceViewSet):
+class PodViewSet(AppFilterViewSet):
     model = models.app.App
     serializer_class = serializers.PodSerializer
 
@@ -449,53 +689,96 @@ def list(self, *args, **kwargs):
         pagination = {'results': data, 'count': len(data)}
         return Response(pagination, status=status.HTTP_200_OK)
 
-    def restart(self, *args, **kwargs):
-        restart_app.delay(self.get_app(), **kwargs)
-        return Response(status=status.HTTP_204_NO_CONTENT)
+    def describe(self, *args, **kwargs):
+        pod_name = kwargs["name"]
+        data = self.get_app().describe_pod(pod_name)
+        if len(data) == 0:
+            raise DryccException("this process not found")
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
 
+    def destroy(self, request, **kwargs):
+        pod_names = request.data.get("pod_ids")
+        pod_names = pod_names.split(",")
+        for pod_name in set(pod_names):
+            delete_pod.delay(self.get_app(), **{"pod_name": pod_name})
+        return Response(status=status.HTTP_200_OK)
 
-class AppSettingsViewSet(AppResourceViewSet):
-    model = models.appsettings.AppSettings
-    serializer_class = serializers.AppSettingsSerializer
 
-    def remove(self, request, *args, **kwargs):
+class PtypeViewSet(AppFilterViewSet):
+    model = models.app.App
+    serializer_class = serializers.PtypeSerializer
+
+    def list(self, *args, **kwargs):
+        deploys = self.get_app().list_deployments(*args, **kwargs)
+        data = self.get_serializer(deploys, many=True).data
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
+
+    def describe(self, *args, **kwargs):
+        deployment_name = kwargs["name"]
+        data = self.get_app().describe_deployment(deployment_name)
+        if len(data) == 0:
+            raise DryccException("this procfile type not found")
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
+
+    def restart(self, request, *args, **kwargs):
         app = self.get_app()
-        app_settings = self.get_object()
-        new_canaries = []
-        remove_canaries = request.data["canaries"]
-        for procfile_tyle in app_settings.canaries:
-            if procfile_tyle not in remove_canaries:
-                new_canaries.append(procfile_tyle)
-        new_app_settings = self.model(app=app, owner=app.owner, canaries=new_canaries)
-        new_app_settings.summary = f"{app.owner} remove canaries {','.join(remove_canaries)}"
-        new_app_settings.save(ignore_update_field=["canaries"])
+        ptypes = set(
+            [ptype for ptype in request.data.get("ptypes", "").split(",") if ptype])
+        ptypes = app.check_ptypes(ptypes)
+        for ptype in set(ptypes):
+            restart_app.delay(app, **{"type": ptype})
         return Response(status=status.HTTP_204_NO_CONTENT)
 
+    def clean(self, request, *args, **kwargs):
+        app = self.get_app()
+        ptypes = set(
+            [ptype for ptype in request.data.get("ptypes", "").split(",") if ptype])
+        if not ptypes:
+            raise DryccException("ptypes is a required field")
+        latest_ptypes = [k for k, v in app.structure.items() if v != 0]
+        not_allow = [ptype for ptype in ptypes if ptype in latest_ptypes]
+        if not_allow:
+            raise DryccException(f'ptype {",".join(not_allow)} should not garbage.')
+        app.clean(ptypes=ptypes)
+        return Response(status=status.HTTP_204_NO_CONTENT)
 
-class CanaryViewSet(AppResourceViewSet):
-    model = models.appsettings.AppSettings
-    serializer_class = serializers.AppSettingsSerializer
-
-    def _clean_canaries(self):
+    def scale(self, request, **kwargs):
         app = self.get_app()
-        app_settings = self.model(app=app, owner=app.owner, canaries=[])
-        app_settings.summary = "canary release"
-        app_settings.save(ignore_update_field=["canaries"])
+        scale_app.delay(app, request.user, request.data)
+        return Response(status=status.HTTP_204_NO_CONTENT)
 
-    def release(self, request, *args, **kwargs):
-        self._clean_canaries()
-        latest_release = self.get_app().release_set.filter(failed=False).latest()
-        data = {'version': latest_release.rollback(request.user, latest_release.version).version}
-        return Response(data, status=status.HTTP_201_CREATED)
 
-    def rollback(self, request, *args, **kwargs):
-        self._clean_canaries()
-        release = self.get_app().release_set.filter(failed=False, canary=False).latest()
-        data = {'version': release.rollback(request.user, release.version).version}
-        return Response(data, status=status.HTTP_201_CREATED)
+class EventViewSet(AppFilterViewSet):
+    model = models.app.App
+    serializer_class = serializers.EventSerializer
+
+    def list(self, request, **kwargs):
+        ptype = request.query_params.get("ptype", None)
+        pod_name = request.query_params.get("pod_name", None)
+        if not any([ptype, pod_name]):
+            data = []
+        else:
+            ref_kind, ref_name = ("Pod", pod_name) if pod_name else \
+                ("Deployment", ptype)
+            events = self.get_app().list_events(ref_kind, ref_name)
+            data = self.get_serializer(events, many=True).data
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
 
 
-class DomainViewSet(AppResourceViewSet):
+class AppSettingsViewSet(AppFilterViewSet):
+    model = models.appsettings.AppSettings
+    serializer_class = serializers.AppSettingsSerializer
+
+
+class DomainViewSet(AppFilterViewSet):
     """A viewset for interacting with Domain objects."""
     model = models.domain.Domain
     serializer_class = serializers.DomainSerializer
@@ -520,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
@@ -534,29 +817,28 @@ def create_or_update(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.get('protocol'))
-        procfile_type = self.get_serializer().validate_procfile_type(request.data.get(
-            'procfile_type'))
+        ptype = self.get_serializer().validate_ptype(request.data.get(
+            'ptype'))
         target_port = self.get_serializer().validate_target_port(request.data.get('target_port'))
-        service = app.service_set.filter(procfile_type=procfile_type).first()
+        service = app.service_set.filter(ptype=ptype).first()
         if service:
             for item in service.ports:
                 if item["port"] == port:
                     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, procfile_type=procfile_type)
+            service = self.model(app=app, ptype=ptype)
             http_status = status.HTTP_201_CREATED
         service.add_port(port, protocol, target_port)
-        service.canary = procfile_type in app.appsettings_set.latest().canaries
         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'))
-        procfile_type = self.get_serializer().validate_procfile_type(
-            request.data.get('procfile_type'))
-        service = get_object_or_404(self.get_queryset(**kwargs), procfile_type=procfile_type)
+        ptype = self.get_serializer().validate_ptype(
+            request.data.get('ptype'))
+        service = get_object_or_404(self.get_queryset(**kwargs), ptype=ptype)
         removed = service.remove_port(port, protocol)
         if len(service.ports) == 0:
             service.delete()
@@ -565,7 +847,7 @@ def delete(self, request, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class CertificateViewSet(BaseDryccViewSet):
+class CertificateViewSet(AppFilterViewSet):
     """A viewset for interacting with Certificate objects."""
     model = models.certificate.Certificate
     serializer_class = serializers.CertificateSerializer
@@ -593,18 +875,17 @@ def detach(self, request, *args, **kwargs):
             self.get_object().detach(*args, **kwargs)
         except Http404:
             raise
-
         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
@@ -614,18 +895,48 @@ def get_object(self, **kwargs):
         qs = self.get_queryset(**kwargs)
         return get_object_or_404(qs, version=self.kwargs['version'])
 
+    def get_queryset(self, **kwargs):
+        ptypes = self.request.query_params.get('ptypes', '').strip()
+        queryset = super().get_queryset(**kwargs)
+        if ptypes:
+            queryset = queryset.filter(Q(
+                deployed_ptypes__contains=[
+                    ptype.lower() for ptype in re.split(r"\W+", ptypes)]))
+        return queryset
+
+    def deploy(self, request, **kwargs):
+        """Deploy the latest release"""
+        latest_release = self.get_app().release_set.latest()
+
+        force_deploy = request.data.get("force", False)
+        ptypes = set(
+            [ptype for ptype in request.data.get("ptypes", "").split(",") if ptype])
+        if not ptypes:
+            ptypes = latest_release.ptypes
+        else:
+            invalid_ptypes = ptypes.difference(
+                latest_release.ptypes + [d["name"] for d in self.get_app().list_deployments()])
+            if len(invalid_ptypes) != 0:
+                raise DryccException(f"process type {','.join(invalid_ptypes)} is not exists")
+        latest_release.deploy(ptypes, force_deploy)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
     def rollback(self, request, **kwargs):
         """
         Create a new release as a copy of the state of the compiled slug and config vars of a
         previous release.
         """
-        latest_release = self.get_app().release_set.filter(failed=False).latest()
-        new_release = latest_release.rollback(request.user, request.data.get('version', None))
+        latest_release = models.release.Release.latest(self.get_app())
+        ptypes = set(
+            [ptype for ptype in request.data.get("ptypes", "").split(",") if ptype])
+        ptypes = latest_release.app.check_ptypes(ptypes)
+        new_release = latest_release.rollback(
+            request.user, ptypes, request.data.get('version', None))
         response = {'version': new_release.version}
         return Response(response, status=status.HTTP_201_CREATED)
 
 
-class TLSViewSet(AppResourceViewSet):
+class TLSViewSet(AppFilterViewSet):
     model = models.tls.TLS
     serializer_class = serializers.TLSSerializer
 
@@ -634,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):
@@ -646,32 +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, 'api.use_app')
-        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'])
-
-        perm_name = "api.use_app"
-        usernames = [u.id for u in get_users_with_perms(app)
-                     if u.has_perm(perm_name, 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:
@@ -690,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, app)
-        if not has_permission:
-            raise PermissionDenied(message)
+        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 \
@@ -700,7 +1000,7 @@ def users(self, request, *args, **kwargs):
                      .values('public', 'fingerprint') \
                      .order_by('created')
         if not keys:
-            raise NotFound("No Keys match the given query.")
+            return Response("No Keys match the given query.", status=status.HTTP_404_NOT_FOUND)
 
         for info in keys:
             data[request.user.username].append({
@@ -720,21 +1020,21 @@ 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(request, app)
-        if not has_permission:
-            raise PermissionDenied(message)
+        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': {
-                'version': app.release_set.filter(failed=False).latest().version
+                'version': models.release.Release.latest(app).version
             }
         }
         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)
 
 
@@ -747,59 +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, app)
-        if not has_permission:
-            raise PermissionDenied(message)
-        config = app.release_set.filter(failed=False).latest().config
+        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 AppPermsViewSet(BaseDryccViewSet):
-    """RESTful views for sharing apps with collaborators."""
-
-    model = models.app.App  # models class
-    perm = 'use_app'    # short name for permission
-
-    def get_queryset(self):
-        return self.model.objects.all()
-
-    def list(self, request, **kwargs):
-        app = self.get_object()
-        perm_name = "api.{}".format(self.perm)
-        usernames = [u.username for u in get_users_with_perms(app)
-                     if u.has_perm(perm_name, app)]
-        return Response({'users': usernames})
-
-    def create(self, request, **kwargs):
-        app = self.get_object()
-        if not permissions.IsOwnerOrAdmin.has_object_permission(permissions.IsOwnerOrAdmin(),
-                                                                request, self, app):
-            raise PermissionDenied()
-
-        user = get_object_or_404(User, username=request.data['username'])
-        assign_perm(self.perm, user, app)
-        app.log("User {} was granted access to {}".format(user, app))
-        return Response(status=status.HTTP_201_CREATED)
-
-    def destroy(self, request, **kwargs):
-        app = get_object_or_404(models.app.App, id=self.kwargs['id'])
-        user = get_object_or_404(User, username=kwargs['username'])
-
-        perm_name = "api.{}".format(self.perm)
-        if not user.has_perm(perm_name, app):
-            raise PermissionDenied()
-
-        if (user != request.user and
-            not permissions.IsOwnerOrAdmin.has_object_permission(permissions.IsOwnerOrAdmin(),
-                                                                 request, self, app)):
-            raise PermissionDenied()
-        remove_perm(self.perm, user, app)
-        app.log("User {} was revoked access to {}".format(user, app))
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-
-class AppVolumesViewSet(ReleasableViewSet):
+class AppVolumesViewSet(AppFilterViewSet):
     """RESTful views for volumes apps with collaborators."""
     model = models.volume.Volume
     serializer_class = serializers.VolumeSerializer
@@ -810,14 +1065,21 @@ def get_object(self):
                                  name=self.kwargs['name'])
 
     def expand(self, request, **kwargs):
-        volume = self.get_object()
-        volume.expand(request.data['size'])
-        serializer = self.get_serializer(volume, many=False)
-        return Response(serializer.data)
+        size, volume = request.data['size'], self.get_object()
+        if volume.type == "csi":
+            if utils.unit_to_bytes(request.data['size']) < utils.unit_to_bytes(volume.size):
+                raise DryccException('Shrink volume is not supported.')
+            volume.size = size
+            volume.save()
+            serializer = self.get_serializer(volume, many=False)
+            return Response(serializer.data)
+        raise DryccException(f'{volume.type} volume is not support expand.')
 
     def destroy(self, request, **kwargs):
         volume = self.get_object()
-        if volume.path != {}:
+        app = self.get_app()
+        is_subset = set(volume.path.keys()).issubset(set(app.ptypes))
+        if volume.path != {} and is_subset:
             raise DryccException("this volume is mounting")
         volume.delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
@@ -829,11 +1091,10 @@ def path(self, request, *args, **kwargs):
         else:
             path = serializers.VolumeSerializer().validate_path(path)
         volume = self.get_object()
-        container_types = [_ for _ in path.keys()
-                           if _ not in volume.app.procfile_types]
-        if container_types:
-            raise DryccException("process type {} is not included in profile".
-                                 format(','.join(container_types)))
+        ptypes = [_ for _ in path.keys() if _ not in volume.app.ptypes]
+        if ptypes:
+            raise DryccException("process type {} is not included in procfile".
+                                 format(','.join(ptypes)))
         if set(path.items()).issubset(set(volume.path.items())):
             raise DryccException("mount path not changed")
         volume.check_path(path)
@@ -844,7 +1105,7 @@ def path(self, request, *args, **kwargs):
         return Response(serializer.data)
 
 
-class AppResourcesViewSet(AppResourceViewSet):
+class AppResourcesViewSet(AppFilterViewSet):
     """RESTful views for resources apps with collaborators."""
     model = models.resource.Resource
     serializer_class = serializers.ResourceSerializer
@@ -867,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
@@ -880,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
 
@@ -899,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
 
@@ -923,10 +1184,10 @@ def binding(self, request, *args, **kwargs):
             logger.info("resoruce unbind response data: {}".format(serializer))
             return Response(serializer.data)
         else:
-            return Http404("unknown action")
+            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]
@@ -940,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'])
@@ -962,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]
@@ -980,46 +1241,17 @@ def set(self, request, *args, **kwargs):
         rules = request.data
         if isinstance(rules, str):
             rules = json.loads(rules)
-        rules = self.get_serializer().validate_rules(rules)
-        route.rules = rules
-        ok, msg = route.check_rules()
+        rules = self.get_serializer(route, many=False).validate_rules(rules)
+        ok, msg = route.check_rules(rules)
         if not ok:
             return Response(status=status.HTTP_400_BAD_REQUEST, data=msg)
+        route.rules = rules
         route.save()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
-    def create(self, request, *args, **kwargs):
-        app = self.get_app()
-        port = self.get_serializer().validate_port(request.data.get('port'))
-        app_settings = app.appsettings_set.latest()
-        procfile_type = self.get_serializer().validate_procfile_type(
-            request.data.get('procfile_type'))
-        kind = self.get_serializer().validate_kind(request.data.get('kind'))
-        name = request.data['name']
-        route = app.route_set.filter(name=name).first()
-        if route:
-            return Response(status=status.HTTP_400_BAD_REQUEST, data={
-                "detail": f"this route {name} already exists"})
-        route = self.model(
-            app=app,
-            owner=app.owner,
-            kind=kind,
-            name=name,
-            port=port,
-            routable=app_settings.routable,
-            procfile_type=procfile_type,
-        )
-        route.rules = route.default_rules
-        if not route.current_rules[0]["backendRefs"]:
-            return Response(status=status.HTTP_400_BAD_REQUEST, data={
-                "detail": "this route does not match services. please add service first."
-            })
-        route.save()
-        return Response(status=status.HTTP_201_CREATED)
-
     def attach(self, request, *args, **kwargs):
         app = self.get_app()
-        port = self.get_serializer().validate_port(request.data.get('port'))
+        port = serializers.validate_port(request.data.get('port'))
         gateway_name = request.data['gateway']
         route = get_object_or_404(self.model, app=app, name=kwargs['name'])
         attached, msg = route.attach(gateway_name, port)
@@ -1030,7 +1262,7 @@ def attach(self, request, *args, **kwargs):
 
     def detach(self, request, *args, **kwargs):
         app = self.get_app()
-        port = self.get_serializer().validate_port(request.data.get('port'))
+        port = serializers.validate_port(request.data.get('port'))
         gateway_name = request.data['gateway']
         route = get_object_or_404(self.model, app=app, name=kwargs['name'])
         detached, msg = route.detach(gateway_name, port)
@@ -1039,64 +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 AdminPermsViewSet(BaseDryccViewSet):
-    """RESTful views for sharing admin permissions with other users."""
-
-    model = User
-    serializer_class = serializers.AdminUserSerializer
-    permission_classes = [permissions.IsAdmin]
-
-    def get_queryset(self, **kwargs):
-        self.check_object_permissions(self.request, self.request.user)
-        return self.model.objects.filter(is_active=True, is_superuser=True)
-
-    def create(self, request, **kwargs):
-        user = get_object_or_404(self.model, username=request.data['username'])
-        user.is_superuser = user.is_staff = True
-        user.save(update_fields=['is_superuser', 'is_staff'])
-        return Response(status=status.HTTP_201_CREATED)
-
-    def destroy(self, request, **kwargs):
-        user = get_object_or_404(self.model, username=kwargs['username'])
-        user.is_superuser = user.is_staff = False
-        user.save(update_fields=['is_superuser', 'is_staff'])
-        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):
@@ -1104,64 +1286,243 @@ def _get_app(self):
         self.check_object_permissions(self.request, app)
         return app
 
-    def _get_cpus(self, app_id, container_type, start, stop, every):
-        avg_list, max_list = [], []
-        for _, _, _, timestamp, max, avg in monitor.query_cpu_usage(
-                app_id, container_type, start, stop, every):
-            max_list.append((timestamp, max))
-            avg_list.append((timestamp, avg))
-        return {
-            "max": max_list,
-            "avg": avg_list,
-        }
-
-    def _get_memory(self, app_id, container_type, start, stop, every):
-        max_list, avg_list = [], []
-        for _, _, _, timestamp, max, avg in monitor.query_memory_usage(
-                app_id, container_type, start, stop, every):
-            max_list.append((timestamp, max))
-            avg_list.append((timestamp, avg))
-        return {
-            "max": max_list,
-            "avg": avg_list,
-        }
-
-    def _get_networks(self, app_id, container_type, start, stop, every):
-        networks = []
-        for _, _, timestamp, rx_bytes, tx_bytes in monitor.query_network_usage(
-                app_id, container_type, start, stop, every):
-            networks.append((timestamp, rx_bytes, tx_bytes))
-        return networks
-
-    @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)
-        start, stop, every = data.validated_data['start'], data.validated_data[
-            'stop'], data.validated_data["every"]
-        return Response({
-            "id": app_id,
-            "type": kwargs['type'],
-            "count": monitor.query_container_count(app_id, kwargs['type'], start, stop),
-            "status": {
-                "cpus": self._get_cpus(
-                    app_id, kwargs['type'], start, stop, every),
-                "memory": self._get_memory(
-                    app_id, kwargs['type'], start, stop, every),
-                "networks": self._get_networks(
-                    app_id, kwargs['type'], start, stop, every),
-            }
-        })
-
     @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
-        streaming_content = monitor.last_metrics(app_id)
         return StreamingHttpResponse(
-            content_type='application/json',
-            streaming_content=streaming_content
+            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 3afb77c1d..218a94568 100755
--- a/rootfs/bin/test-unit
+++ b/rootfs/bin/test-unit
@@ -6,21 +6,13 @@
 # fail hard and fast even on pipelines
 set -eou pipefail
 
-function start_redis() {
+function start_valkey() {
     cd /tmp
-    nohup redis-server > /var/log/redis.log 2>&1 &
-    cd -
-}
-
-function start_rabbitmq() {
-    cd /tmp
-    nohup rabbitmq-server > /var/log/rabbitmq.log 2>&1 &
+    nohup valkey-server > /var/log/valkey.log 2>&1 &
     cd -
 }
 
 gosu postgres pg_ctl -D "$PGDATA" start && ln -s /tmp/.s.PGSQL.5432 /var/run/postgresql/.s.PGSQL.5432
-start_redis
-start_rabbitmq
+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 report -m
+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/bin/upload-coverage b/rootfs/bin/upload-coverage
index 53433f3d0..492452b18 100755
--- a/rootfs/bin/upload-coverage
+++ b/rootfs/bin/upload-coverage
@@ -6,4 +6,8 @@
 # fail hard and fast even on pipelines
 set -eou pipefail
 
-codecov --required
+coverage report -m > coverage.txt
+
+if [[ -n $CODECOV_TOKEN ]]; then
+  codecov upload-process --plugin=noop -t "$CODECOV_TOKEN"
+fi
diff --git a/rootfs/dev_requirements.txt b/rootfs/dev_requirements.txt
index 5e6b68009..1f133f1de 100644
--- a/rootfs/dev_requirements.txt
+++ b/rootfs/dev_requirements.txt
@@ -1,13 +1,13 @@
 # test module
 # test
 # Run "make test-unit" for the % of code exercised during tests
-coverage==7.4.4
+coverage==7.10.6
 
 # Run "make test-style" to check python syntax and style
-flake8==7.0.0
-
-# code coverage report at https://codecov.io/github/drycc/controller
-codecov==2.1.13
+flake8==7.3.0
 
 # mock out python-requests, mostly k8s
-requests-mock==1.11.0
+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 9927f2f40..9a980075c 100644
--- a/rootfs/drycc/gunicorn/config.py
+++ b/rootfs/drycc/gunicorn/config.py
@@ -4,27 +4,26 @@
 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'
 
-workers = int(os.environ.get('GUNICORN_WORKERS', 2))
+workers = int(os.environ.get('GUNICORN_WORKERS', 4))
+timeout = int(os.environ.get('GUNICORN_TIMEOUT', 30))
+keepalive = int(os.environ.get('GUNICORN_KEEPALIVE', 60))
 worker_class = "uvicorn.workers.UvicornWorker"
 pythonpath = dirname(dirname(dirname(realpath(__file__))))
-timeout = 1200
 pidfile = '/tmp/gunicorn.pid'
 logger_class = 'drycc.gunicorn.logging.Logging'
 loglevel = 'info'
diff --git a/rootfs/requirements.txt b/rootfs/requirements.txt
index 2d0c52e64..0c80ff269 100644
--- a/rootfs/requirements.txt
+++ b/rootfs/requirements.txt
@@ -1,32 +1,25 @@
 # Drycc controller requirements
-inotify==0.2.10
+inotify==0.2.12
 backoff==2.2.1
-django==4.2.11
-channels==4.0.0
-aiohttp==3.9.4
-django-cors-headers==4.3.1
-django-guardian==2.4.0
-djangorestframework==3.15.1
-docker==7.0.0
-gunicorn==22.0.0
-uvicorn[standard]==0.29.0
-asgiref==3.8.0
-idna==3.7
-jsonschema==4.21.1
+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.38.0
+asgiref==3.10.0
+idna==3.11
+jsonschema==4.25.1
 morph==0.1.5
-packaging==24.0
-pyasn1==0.5.1
-psycopg[binary]==3.1.18
-pyOpenSSL==24.1.0
-ndg-httpsclient==0.5.1
-pytz==2024.1
-requests==2.31.0
+packaging==25.0
+psycopg[binary]==3.2.12
+pytz==2025.2
+requests==2.33.0
 requests-toolbelt==1.0.0
-celery==5.3.6
-hiredis==2.3.2
-django_redis==5.4.0
-dj-database-url==2.1.0
-social-auth-app-django==5.4.1
-python-jose==3.3.0
-Authlib==1.3.0
-kubernetes==29.0.0
+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 6e5200e28..223364a42 100644
--- a/rootfs/scheduler/__init__.py
+++ b/rootfs/scheduler/__init__.py
@@ -8,13 +8,12 @@
 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
 
 
 logger = logging.getLogger(__name__)
 session = None
-resource_mapping = OrderedDict()
 
 
 def get_k8s_session(k8s_api_verify_tls):
@@ -40,12 +39,13 @@ class KubeHTTPClient(object):
     api_prefix = 'api'
     # ISO-8601 which is used by kubernetes
     DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
+    resource_mapping = OrderedDict()
 
-    def __init__(self, url, k8s_api_verify_tls=True):
-        global resource_mapping
+    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
@@ -53,33 +53,32 @@ def __init__(self, url, k8s_api_verify_tls=True):
             name = str(res.__name__).lower()  # singular
             component = name + 's'  # make plural
             # check if component has already been processed
-            if component in resource_mapping:
+            if component in self.resource_mapping:
                 continue
 
             # get past recursion problems in case of self reference
-            resource_mapping[component] = ''
-            resource_mapping[component] = res(self.url, self.k8s_api_verify_tls)
+            self.resource_mapping[component] = ''
+            self.resource_mapping[component] = res(self.url, self.k8s_api_verify_tls)
             # map singular Resource name to the plural one
-            resource_mapping[name] = component
+            self.resource_mapping[name] = component
             if res.short_name is not None:
                 # map short name to long name so a resource can be named rs
                 # but have the main object live at replicasets
-                resource_mapping[str(res.short_name).lower()] = component
+                self.resource_mapping[str(res.short_name).lower()] = component
 
     def api(self, tmpl, *args):
         """Return a fully-qualified Kubernetes API URL from a string template with args."""
         return "/{}/{}".format(self.api_prefix, self.api_version) + tmpl.format(*args)
 
     def __getattr__(self, name):
-        global resource_mapping
-        if name in resource_mapping:
+        if name in self.resource_mapping:
             # resolve to final name if needed
-            component = resource_mapping[name]
+            component = self.resource_mapping[name]
             if type(component) is not str:
                 # already a component object
                 return component
 
-            return resource_mapping[component]
+            return self.resource_mapping[component]
 
         return object.__getattribute__(self, name)
 
@@ -142,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".
@@ -150,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):
         """
@@ -192,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.
@@ -209,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.
@@ -231,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 c7f779622..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',
 ]
 
 
@@ -368,6 +371,7 @@ def create_pods(url, labels, base, new_pods):
             'containerStatuses': [
                 {
                     'name': '{}-{}'.format(labels['app'], labels['type']),
+                    'image': '127.0.0.1/{}-{}'.format(labels['app'], labels['type']),
                     # TODO ready can be True / False (boolean)
                     'ready': True,
                     # TODO can be running / terminated / waiting
@@ -375,7 +379,17 @@ def create_pods(url, labels, base, new_pods):
                         'running': {
                             'startedAt': timestamp
                         }
-                    }
+                    },
+                    'lastState': {
+                        "terminated": {
+                            "containerID": f"cri-o://{uuid.uuid4().hex * 2}",
+                            "exitCode": 1,
+                            "finishedAt": timestamp,
+                            "reason": "Error",
+                            "startedAt": timestamp
+                        }
+                    },
+                    'restartCount': 0,
                 }
             ],
         }
@@ -715,7 +729,9 @@ def post(request, context):
     # Handle RC / RS / Deployments
     if resource_type in ['replicationcontrollers', 'replicasets', 'deployments']:
         data['status'] = {
-            'observedGeneration': 1
+            'observedGeneration': 1,
+            'readyReplicas': 1,
+            'replicas': 1
         }
         data['metadata']['generation'] = 1
 
@@ -889,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']
@@ -922,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)
@@ -981,7 +1001,8 @@ def add_cache_item(url, resource_type, data):
 
     # Keep track of what resources exist under other resources (mostly namespace)
     # sneaky way of getting data up to the resource type without too much magic
-    cache_url = ''.join(url.partition(resource_type)[0:2])
+    index = url.rfind(resource_type)
+    cache_url = url[:index + len(resource_type)] if index != -1 else url
     items = cache.get(cache_url, [])
     if url not in items:
         items.append(url)
@@ -1057,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)
@@ -1079,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:
@@ -1169,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 43f269377..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
 
@@ -9,7 +10,7 @@ class Deployment(Resource):
     api_prefix = 'apis'
     api_version = 'apps/v1'
 
-    def get(self, namespace, name=None, **kwargs):
+    def get(self, namespace, name=None, ignore_exception=False, **kwargs):
         """
         Fetch a single Deployment or a list
         """
@@ -24,7 +25,7 @@ def get(self, namespace, name=None, **kwargs):
 
         url = self.api(url, *args)
         response = self.http_get(url, params=self.query_params(**kwargs))
-        if self.unhealthy(response.status_code):
+        if self.unhealthy(response.status_code) and not ignore_exception:
             args.reverse()  # error msg is in reverse order
             raise KubeHTTPException(response, message, *args)
 
@@ -57,8 +58,6 @@ def manifest(self, namespace, name, image, command, args, spec_annotations, **kw
                 'selector': {
                     'matchLabels': labels
                 },
-                'nodeSelector': kwargs.get('node_selector', {}),
-                'securityContext': kwargs.get('pod_security_context', {}),
             }
         }
 
@@ -108,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:
@@ -115,40 +115,45 @@ def manifest(self, namespace, name, image, command, args, spec_annotations, **kw
 
         return manifest
 
-    def create(self, namespace, name, image, command, args, spec_annotations, **kwargs):
+    def create(self, namespace, name, image, command, args,
+               spec_annotations, ignore_exception=False, **kwargs):
         manifest = self.manifest(namespace, name, image,
                                  command, args, spec_annotations, **kwargs)
 
         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')
-            raise KubeHTTPException(
-                response,
-                'create Deployment "{}" in Namespace "{}"', name, namespace
-            )
-
-        self.wait_until_updated(namespace, name)
-        self.wait_until_ready(namespace, name, **kwargs)
-
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
+            if not ignore_exception:
+                raise KubeHTTPException(
+                    response,
+                    'create Deployment "{}" in Namespace "{}"', name, namespace
+                )
+        else:
+            self.wait_until_updated(namespace, name)
+            self.wait_until_ready(namespace, name, **kwargs)
         return response
 
-    def update(self, namespace, name, image, command, args, spec_annotations, **kwargs):
+    def update(self, namespace, name, image, command, args,
+               spec_annotations, ignore_exception=False, **kwargs):
         manifest = self.manifest(namespace, name, image,
                                  command, args, spec_annotations, **kwargs)
 
         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')
-            raise KubeHTTPException(response, 'update Deployment "{}"', name)
-
-        self.wait_until_updated(namespace, name)
-        self.wait_until_ready(namespace, name, **kwargs)
-
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
+            if not ignore_exception:
+                raise KubeHTTPException(response, 'update Deployment "{}"', name)
+        else:
+            self.wait_until_updated(namespace, name)
+            self.wait_until_ready(namespace, name, **kwargs)
         return response
 
-    def patch(self, namespace, name, image, command, args, spec_annotations, **kwargs):
+    def patch(self, namespace, name, image, command, args,
+              spec_annotations, ignore_exception=False, **kwargs):
         manifest = self.manifest(namespace, name, image,
                                  command, args, spec_annotations, **kwargs)
 
@@ -160,12 +165,13 @@ def patch(self, namespace, name, image, command, args, spec_annotations, **kwarg
         )
 
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')
-            raise KubeHTTPException(response, 'patch Deployment "{}"', name)
-
-        self.wait_until_updated(namespace, name)
-        self.wait_until_ready(namespace, name, **kwargs)
-
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
+            if not ignore_exception:
+                raise KubeHTTPException(response, 'patch Deployment "{}"', name)
+        else:
+            self.wait_until_updated(namespace, name)
+            self.wait_until_ready(namespace, name, **kwargs)
         return response
 
     def delete(self, namespace, name, ignore_exception=False):
@@ -200,7 +206,7 @@ def scale(self, namespace, name, **kwargs):
             kwargs['previous_replicas'] = current
             self.wait_until_ready(namespace, name, **kwargs)
 
-    def restart(self, namespace, name):
+    def restart(self, namespace, name, ignore_exception=False):
         url = self.api(
             "/namespaces/{}/deployments/{}?fieldManager=kubectl-rollout&pretty=true",
             namespace, name
@@ -221,7 +227,7 @@ def restart(self, namespace, name):
             }),
             headers={"Content-Type": "application/merge-patch+json"},
         )
-        if self.unhealthy(response.status_code):
+        if self.unhealthy(response.status_code) and not ignore_exception:
             raise KubeHTTPException(
                 response,
                 'restart Deployment "{}" in Namespace "{}"', name, namespace
@@ -244,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
@@ -279,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
 
@@ -300,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
 
@@ -347,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()
@@ -355,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)
@@ -436,12 +442,12 @@ def _check_for_failed_events(self, namespace, labels):
         response = self.rs.get(namespace, labels=labels)
         data = response.json()
         fields = {
-            'involvedObject.kind': 'ReplicaSet',
-            'involvedObject.name': data['items'][0]['metadata']['name'],
-            'involvedObject.namespace': namespace,
-            'involvedObject.uid': data['items'][0]['metadata']['uid'],
+            'regarding.kind': 'ReplicaSet',
+            'regarding.name': data['items'][0]['metadata']['name'],
+            'regarding.namespace': namespace,
+            'regarding.uid': data['items'][0]['metadata']['uid'],
         }
-        events_list = self.ns.events(namespace, fields=fields).json()
+        events_list = self.ev.get(namespace, fields=fields).json()
         events = events_list.get('items', [])
         if events is not None and len(events) != 0:
             for event in events:
@@ -455,10 +461,11 @@ def _get_formatted_messages(events):
         """
         Format each event by string and join all events to one string
         """
-        message_format = 'Message:{message}, lastTimestamp:{lastTimestamp}, reason: {reason}, count: {count}'  # noqa
+        message_format = 'Message: {note}, creationTimestamp: {creation_timestamp}, reason: {reason}'  # noqa
         output = []
         for event in events:
-            output.append(message_format.format(**event))
+            creation_timestamp = event["metadata"]["creationTimestamp"]
+            output.append(message_format.format(creation_timestamp=creation_timestamp, **event))
         return '\n'.join(output)
 
     def _get_deploy_steps(self, batches, tags):
diff --git a/rootfs/scheduler/resources/events.py b/rootfs/scheduler/resources/events.py
index 3187defab..749098ac8 100644
--- a/rootfs/scheduler/resources/events.py
+++ b/rootfs/scheduler/resources/events.py
@@ -9,8 +9,9 @@
 class Events(Resource):
     """
     Events resource.
-    Warning! Used ONLY for testing purposes
     """
+    api_prefix = 'apis'
+    api_version = 'events.k8s.io/v1'
     short_name = 'ev'
 
     def create(self, namespace, name, message, **kwargs):
@@ -18,7 +19,6 @@ def create(self, namespace, name, message, **kwargs):
         data = {
             'kind': 'Event',
             'apiVersion': self.api_version,
-            'count': kwargs.get('count', 1),
             'metadata': {
                 'creationTimestamp': datetime.now().strftime(DATETIME_FORMAT),
                 'namespace': namespace,
@@ -26,15 +26,10 @@ def create(self, namespace, name, message, **kwargs):
                 'resourceVersion': kwargs.get('resourceVersion', ''),
                 'uid': str(uuid.uuid4()),
             },
-            'message': message,
+            'note': message,
             'type': kwargs.get('type', 'Normal'),
-            'firstTimestamp': datetime.now().strftime(DATETIME_FORMAT),
-            'lastTimestamp': datetime.now().strftime(DATETIME_FORMAT),
             'reason': kwargs.get('reason', ''),
-            'source': {
-                'component': kwargs.get('component', ''),
-            },
-            'involvedObject': kwargs.get('involvedObject', {})
+            'regarding': kwargs.get('regarding', {})
         }
 
         response = self.http_post(url, json=data)
@@ -42,3 +37,18 @@ def create(self, namespace, name, message, **kwargs):
             raise KubeHTTPException(response, 'create Event for namespace {}'.format(namespace))  # noqa
 
         return response
+
+    def get(self, namespace, ignore_exception=False, **kwargs):
+        """
+        Fetch Events
+        """
+        url = '/namespaces/{}/events'
+        args = [namespace]
+        message = 'get Events in Namespace "{}"'
+
+        url = self.api(url, *args)
+        response = self.http_get(url, params=self.query_params(**kwargs))
+        if self.unhealthy(response.status_code) and not ignore_exception:
+            args.reverse()  # error msg is in reverse order
+            raise KubeHTTPException(response, message, *args)
+        return response
diff --git a/rootfs/scheduler/resources/gateway.py b/rootfs/scheduler/resources/gateway.py
index 7f4710dd1..a51ff538a 100644
--- a/rootfs/scheduler/resources/gateway.py
+++ b/rootfs/scheduler/resources/gateway.py
@@ -13,9 +13,7 @@ def manifest(self, namespace, name, **kwargs):
             "metadata": {
                 "name": name,
                 "namespace": namespace,
-                "annotations": {
-                    "cert-manager.io/issuer": namespace
-                },
+                "annotations": kwargs.get("annotations", {}),
             },
             "spec": {
                 "gatewayClassName": kwargs.get("gateway_class", "default"),
@@ -61,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):
@@ -130,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/namespace.py b/rootfs/scheduler/resources/namespace.py
index ed420b9c5..120c9ee34 100644
--- a/rootfs/scheduler/resources/namespace.py
+++ b/rootfs/scheduler/resources/namespace.py
@@ -52,11 +52,3 @@ def delete(self, namespace):
             raise KubeHTTPException(response, 'delete Namespace "{}"', namespace)
 
         return response
-
-    def events(self, namespace, **kwargs):
-        url = self.api("/namespaces/{}/events", namespace)
-        response = self.http_get(url, params=self.query_params(**kwargs))
-        if self.unhealthy(response.status_code):
-            raise KubeHTTPException(response, "get Events in Namespace {}", namespace)
-
-        return response
diff --git a/rootfs/scheduler/resources/pod.py b/rootfs/scheduler/resources/pod.py
index 89213cb32..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
@@ -134,12 +133,9 @@ def manifest(self, namespace, name, image, **kwargs):
         app_type = kwargs.get('app_type')
 
         # labels that represent the pod(s)
-        labels = {
-            'app': namespace,
-            'version': kwargs.get('version'),
-            'type': app_type,
-            'heritage': 'drycc',
-        }
+        default_labels = {'app': namespace, 'version': kwargs.get('version'), 'heritage': 'drycc'}
+        labels = kwargs.get('labels', default_labels)
+        labels.update({'type': app_type})
 
         # create base pod structure
         manifest = {
@@ -152,7 +148,9 @@ def manifest(self, namespace, name, image, **kwargs):
               'annotations': kwargs.get('annotations', {}),
             },
             'spec': {
-                "affinity": self.affinity(name)
+                "affinity": self.affinity(name),
+                'nodeSelector': kwargs.get('node_selector', {}),
+                'securityContext': kwargs.get('pod_security_context', {}),
             }
         }
         # pod manifest spec
@@ -167,10 +165,10 @@ def manifest(self, namespace, name, image, **kwargs):
         spec['restartPolicy'] = kwargs.get('restart_policy', 'Always')
 
         # apply tags as needed to restrict pod to particular node(s)
-        spec['nodeSelector'] = kwargs.get('tags', {})
+        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
@@ -221,42 +219,43 @@ def _set_container(self, namespace, container_name, data, **kwargs):
             for key in env.keys():
                 item = {
                     "name": key,
-                    "valueFrom": {
-                        "secretKeyRef": {
-                            "name": secret_name,
-                            # k8s doesn't allow _ so translate to -, see above
-                            "key": key.lower().replace('_', '-')
-                        }
-                    }
+                    "valueFrom": {"secretKeyRef": {
+                        "name": secret_name,
+                        # k8s doesn't allow _ so translate to -, see above
+                        "key": key.lower().replace('_', '-')
+                    }}
                 }
-
                 # add value to env hash. Overwrite hardcoded values if need be
                 match = next((k for k, e in enumerate(data["env"]) if e['name'] == key), None)
                 if match is not None:
                     data["env"][match] = item
                 else:
                     data["env"].append(item)
-
-        # Inject debugging if workflow is in debug mode
-        if os.environ.get("DRYCC_DEBUG", False):
-            data["env"].append({
-                "name": "DRYCC_DEBUG",
-                "value": "1"
-            })
-
+        # set container default env
+        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)
-
-    @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)
+    def _set_container_default_env(self, data):
+        # set fields env
+        fields = {"CONTAINER_IP": "status.podIP"}
+        for key, value in fields.items():
+            item = {
+                "name": key,
+                "valueFrom": {"fieldRef": {"fieldPath": value}}
+            }
+            # add value to env hash. Overwrite hardcoded values if need be
+            match = next((k for k, e in enumerate(data["env"]) if e['name'] == key), None)
+            if match is not None:
+                data["env"][match] = item
+            else:
+                data["env"].append(item)
+        # Inject debugging if workflow is in debug mode
+        if os.environ.get("DRYCC_DEBUG", False):
+            data["env"].append({"name": "DRYCC_DEBUG", "value": "1"})
+        return data
 
     @staticmethod
     def _get_memory(size):
@@ -281,43 +280,11 @@ def _set_health_checks(self, container, env, **kwargs):
                 healthchecks['livenessProbe']['httpGet']['port'] = env.get(
                     'PORT', DEFAULT_CONTAINER_PORT)
             container.update(healthchecks)
-        elif kwargs.get('routable', False):
+        if kwargs.get('routable', False) and healthchecks.get('readinessProbe') is None:
             # If routable, set the default probe
             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):
@@ -404,7 +371,7 @@ def readiness_status(self, pod):
         """Check if the pod container have passed the readiness probes"""
         name = '{}-{}'.format(pod['metadata']['labels']['app'], pod['metadata']['labels']['type'])
         # find the right container in case there are many on the pod
-        container = self.find_container(name, pod['status']['containerStatuses'])
+        container = self.find_container(name, pod['status'].get('containerStatuses', []))
         if container is None:
             # Seems like the most sensible default
             return 'Unknown'
@@ -457,7 +424,7 @@ def pending_status(self, pod):
 
         name = '{}-{}'.format(pod['metadata']['labels']['app'], pod['metadata']['labels']['type'])
         # find the right container in case there are many on the pod
-        container = self.pod.find_container(name, pod['status']['containerStatuses'])
+        container = self.find_container(name, pod['status'].get('containerStatuses', []))
         if container is None:
             # Return Pending if nothing else can be found
             return 'Pending', ''
@@ -477,7 +444,7 @@ def pending_status(self, pod):
                     return reason, message
 
                 event = events.pop()
-                return event['reason'], event['message']
+                return event['reason'], event['note']
 
             return reason, message
 
@@ -488,17 +455,16 @@ def events(self, pod):
         """Process events for a given Pod to find if Pulling is happening, among other events"""
         # fetch all events for this pod
         fields = {
-            'involvedObject.name': pod['metadata']['name'],
-            'involvedObject.namespace': pod['metadata']['namespace'],
-            'involvedObject.uid': pod['metadata']['uid']
+            'regarding.name': pod['metadata']['name'],
+            'regarding.namespace': pod['metadata']['namespace'],
+            'regarding.uid': pod['metadata']['uid']
         }
-        events = self.ns.events(
+        events = self.ev.get(
             pod['metadata']['namespace'], fields=fields).json()['items']
         if not events:
             events = []
         # make sure that events are sorted
-        events.sort(
-            key=lambda x: x['lastTimestamp'] if x['lastTimestamp'] else "")
+        events.sort(key=lambda x: x['metadata']['creationTimestamp'] if x['metadata']['creationTimestamp'] else "")  # noqa
         return events
 
     def _handle_pod_errors(self, pod, reason, message):
@@ -515,21 +481,22 @@ def _handle_pod_errors(self, pod, reason, message):
             'ImagePullBackOff',
             'RegistryUnavailable',
             'ErrImageInspect',
+            "CreateContainerError",
+            "CrashLoopBackOff",
         ]
         # Image event reason mapping
         event_errors = {
             "Failed": "FailedToPullImage",
             "InspectFailed": "FailedToInspectImage",
             "ErrImageNeverPull": "ErrImageNeverPullPolicy",
-            # Not including this one for now as the message is not useful
-            # "BackOff": "BackOffPullImage",
+            "BackOff": "CrashLoopBackOff",
             # FailedScheduling relates limits
             "FailedScheduling": "FailedScheduling",
         }
 
         # Nicer error than from the event
         # Often this gets to ImageBullBackOff before we can introspect tho
-        if reason == 'ErrImagePull':
+        if reason in ['ErrImagePull', 'CreateContainerError']:
             raise KubeException(message)
 
         # collect all error messages of worth
@@ -540,7 +507,7 @@ def _handle_pod_errors(self, pod, reason, message):
                     # only show a given error once
                     event_errors.pop(event['reason'])
                     # strip out whitespaces on either side
-                    message = "\n".join([x.strip() for x in event['message'].split("\n")])
+                    message = "\n".join([x.strip() for x in event['note'].split("\n")])
                     messages.append(message)
 
         if messages:
@@ -584,7 +551,13 @@ def _handle_pending_pods(self, namespace, labels):
             pods = []
         for pod in pods:
             # only care about pods that are not starting or in the starting phases
-            if pod['status']['phase'] not in ['Pending', 'ContainerCreating']:
+            phase = pod['status']['phase']
+            name = '{}-{}'.format(pod['metadata']['labels']['app'],
+                                  pod['metadata']['labels']['type'])
+            container = self.find_container(name, pod['status'].get('containerStatuses', []))
+            # phase is Running, but state is waiting in CrashLoopBackOff
+            if phase not in ['Pending', 'ContainerCreating'] and \
+                    (phase == 'Running' and 'waiting' not in container['state'].keys()):
                 continue
 
             # Get more information on why a pod is pending
@@ -733,43 +706,51 @@ def _handle_not_ready_pods(self, namespace, labels):
 
             name = '{}-{}'.format(pod['metadata']['labels']['app'], pod['metadata']['labels']['type'])  # noqa
             # find the right container in case there are many on the pod
-            container = self.find_container(name, pod['status']['containerStatuses'])
+            container = self.find_container(name, pod['status'].get('containerStatuses', []))
             if container is None or container['ready'] == 'true':
                 continue
 
             for event in self.events(pod):
                 if event['reason'] == 'Unhealthy':
                     # strip out whitespaces on either side
-                    message = "\n".join([x.strip() for x in event['message'].split("\n")])
+                    message = "\n".join([x.strip() for x in event['note'].split("\n")])
                     raise KubeException(message)
 
+    def _get_probe_timeout(self, probe):
+        period_seconds = probe.get('periodSeconds', 10)
+        timeout_seconds = probe.get('timeoutSeconds', 1)
+        failure_threshold = probe.get('failureThreshold', 3)
+        success_threshold = probe.get('successThreshold', 1)
+        initial_delay_seconds = probe.get('initialDelaySeconds', 0)
+        if period_seconds > initial_delay_seconds:
+            timeout = 0
+        else:
+            timeout = initial_delay_seconds
+        timeout += (period_seconds + timeout_seconds) * failure_threshold
+        timeout += (period_seconds + timeout_seconds) * success_threshold
+        return int(timeout)
+
     def deploy_probe_timeout(self, timeout, namespace, labels, containers):
         """
         Added in additional timeouts based on readiness and liveness probe
 
         Uses the max of the two instead of combining them as the checks are stacked.
         """
-
         container_name = '{}-{}'.format(labels['app'], labels['type'])
         container = self.pod.find_container(container_name, containers)
 
         # get health info from container
         added_timeout = []
         if 'readinessProbe' in container:
-            # If there is initial delay on the readiness check then timeout needs to be higher
-            # this is to account for kubernetes having readiness check report as failure until
-            # the initial delay period is up
-            added_timeout.append(int(container['readinessProbe'].get('initialDelaySeconds', 50)))
-
+            added_timeout.append(self._get_probe_timeout(container['readinessProbe']))
         if 'livenessProbe' in container:
-            # If there is initial delay on the readiness check then timeout needs to be higher
-            # this is to account for kubernetes having liveness check report as failure until
-            # the initial delay period is up
-            added_timeout.append(int(container['livenessProbe'].get('initialDelaySeconds', 50)))
-
+            added_timeout.append(self._get_probe_timeout(container['livenessProbe']))
         if added_timeout:
             delay = max(added_timeout)
-            self.log(namespace, "adding {}s on to the original {}s timeout to account for the initial delay specified in the liveness / readiness probe".format(delay, timeout))  # noqa
+            self.log(
+                namespace,
+                "adding {}s on to the original {}s timeout for the probe".format(delay, timeout))
             timeout += delay
-
+        if 'startupProbe' in container:
+            timeout += self._get_probe_timeout(container['startupProbe'])
         return timeout
diff --git a/rootfs/scheduler/resources/pv.py b/rootfs/scheduler/resources/pv.py
new file mode 100644
index 000000000..56f6a9e27
--- /dev/null
+++ b/rootfs/scheduler/resources/pv.py
@@ -0,0 +1,75 @@
+import json
+import logging
+from api import utils
+from scheduler.resources import Resource
+from scheduler.exceptions import KubeHTTPException
+
+
+class PersistentVolume(Resource):
+    short_name = 'pv'
+
+    def manifest(self, name, version=None, **kwargs):
+        labels = {
+            'heritage': 'drycc',
+        }
+        data = {
+            "apiVersion": self.api_version,
+            "kind": "PersistentVolume",
+            "metadata": {
+                "name": name,
+                'labels': labels
+            },
+        }
+        data = utils.dict_merge(data, kwargs)
+        if version:
+            data["metadata"]["resourceVersion"] = version
+        return data
+
+    def get(self, name=None):
+        """
+        Fetch a single persistentvolume or a list of persistentvolumes
+        """
+        if name is not None:
+            url = self.api('/persistentvolumes/{}', name)
+            message = 'get persistentvolume ' + name
+        else:
+            url = self.api('/persistentvolumes')
+            message = 'get persistentvolumes'
+        response = self.http_get(url)
+        if self.unhealthy(response.status_code):
+            raise KubeHTTPException(response, message)
+        return response
+
+    def create(self, name, **kwargs):
+        """
+        Create persistentvolume
+        """
+        url = self.api('/persistentvolumes')
+        data = self.manifest(name, **kwargs)
+        response = self.http_post(url, json=data)
+        if not response.status_code == 201:
+            raise KubeHTTPException(
+                response,
+                "create persistentvolume"
+            )
+        return response
+
+    def patch(self, name, **kwargs):
+        url = self.api('/persistentvolumes/{}', name)
+        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)), logging.DEBUG)
+            raise KubeHTTPException(response, 'update persistentvolume "{}"', name)
+        return response
+
+    def delete(self, name):
+        """
+        Delete persistentvolume
+        """
+        url = self.api('/persistentvolumes/{}', name)
+        response = self.http_delete(url)
+        if self.unhealthy(response.status_code):
+            raise KubeHTTPException(response,
+                                    'delete persistentvolume ' + name)
+        return response
diff --git a/rootfs/scheduler/resources/pvc.py b/rootfs/scheduler/resources/pvc.py
index 4ccfbf629..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
 
@@ -17,20 +19,9 @@ def manifest(self, namespace, name, version=None, **kwargs):
                 "name": name,
                 "namespace": namespace,
                 'labels': labels
-            },
-            "spec": {
-                "accessModes": [
-                    "ReadWriteMany"
-                ],
-                "resources": {
-                    "requests": {
-                        "storage": kwargs.get('size')
-                    },
-                },
-                "storageClassName": kwargs.get("storage_class"),
-                "volumeMode": "Filesystem",
             }
         }
+        data = utils.dict_merge(data, kwargs)
         if version:
             data["metadata"]["resourceVersion"] = version
         return data
@@ -70,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/secret.py b/rootfs/scheduler/resources/secret.py
index ad7d3f1eb..41424322c 100644
--- a/rootfs/scheduler/resources/secret.py
+++ b/rootfs/scheduler/resources/secret.py
@@ -117,10 +117,10 @@ def update(self, namespace, name, data, secret_type='Opaque', labels={}):
 
         return response
 
-    def delete(self, namespace, name):
+    def delete(self, namespace, name, ignore_exception=False):
         url = self.api("/namespaces/{}/secrets/{}", namespace, name)
         response = self.http_delete(url)
-        if self.unhealthy(response.status_code):
+        if not ignore_exception and self.unhealthy(response.status_code):
             raise KubeHTTPException(
                 response,
                 'delete Secret "{}" in Namespace "{}"', name, namespace
diff --git a/rootfs/scheduler/resources/service.py b/rootfs/scheduler/resources/service.py
index 7a7ae7fe5..f8d5e1745 100644
--- a/rootfs/scheduler/resources/service.py
+++ b/rootfs/scheduler/resources/service.py
@@ -28,8 +28,8 @@ def manifest(self, namespace, name, **kwargs):
         }
         if "version" in kwargs:
             data["metadata"]["resourceVersion"] = kwargs.get("version")
-        if "procfile_type" in kwargs:
-            data["spec"]["selector"]['type'] = kwargs.get("procfile_type")
+        if "ptype" in kwargs:
+            data["spec"]["selector"]['type'] = kwargs.get("ptype")
         return data
 
     def get(self, namespace, name=None, **kwargs):
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 40cb6eb53..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',
@@ -342,20 +342,20 @@ def test_check_for_failed_events(self):
         deployment = self.scheduler.deployment.get(self.namespace, deploy_name).json()
         response = self.scheduler.rs.get(self.namespace, labels=deployment['metadata']['labels'])
         rs = response.json()
-        involved_object = {
-            'involvedObject.kind': 'ReplicaSet',
-            'involvedObject.name': rs['items'][0]['metadata']['name'],
-            'involvedObject.namespace': self.namespace,
-            'involvedObject.uid': rs['items'][0]['metadata']['uid'],
+        regarding = {
+            'regarding.kind': 'ReplicaSet',
+            'regarding.name': rs['items'][0]['metadata']['name'],
+            'regarding.namespace': self.namespace,
+            'regarding.uid': rs['items'][0]['metadata']['uid'],
         }
         message = 'Quota exeeded'
         self.scheduler.ev.create(self.namespace,
                                  '{}'.format(generate_random_name()),
                                  message,
                                  type='Warning',
-                                 involved_object=involved_object,
+                                 regarding=regarding,
                                  reason='FailedCreate')
         with self.assertRaisesRegex(KubeException,
-                                    'Message:{}.*'.format(message)):
+                                    'Message: {}.*'.format(message)):
             self.scheduler.deployment._check_for_failed_events(self.namespace,
                                                                labels=deployment['metadata']['labels'])  # noqa
diff --git a/rootfs/scheduler/tests/test_events.py b/rootfs/scheduler/tests/test_events.py
new file mode 100644
index 000000000..89b17e276
--- /dev/null
+++ b/rootfs/scheduler/tests/test_events.py
@@ -0,0 +1,38 @@
+"""
+Unit tests for the Drycc scheduler module.
+
+Run the tests with './manage.py test scheduler'
+"""
+from scheduler.tests import TestCase
+
+
+class EventTest(TestCase):
+    """Tests scheduler pod calls"""
+
+    def create_event(self, namespace, name, **kwargs):
+        """
+        Helper function to create and verify a events on the namespace
+        """
+        self.scheduler.ns.create(namespace)
+
+        message = "Scaled down replica set test-869947c55f to 1 from 2"
+        # these are all required even if it is kwargs...
+        kwargs = {
+            'reason': 'ScalingReplicaSet',
+            'type': 'Normal'
+        }
+        return self.scheduler.events.create(
+            namespace,
+            name,
+            message,
+            **kwargs
+        )
+
+    def test_create(self):
+        response = self.create_event("test-event", "test-event")
+        self.assertEqual(response.status_code, 201)
+
+    def test_get_events(self):
+        # test success
+        response = self.scheduler.events.get("test-event", "test-event")
+        self.assertEqual(response.status_code, 200)
diff --git a/rootfs/scheduler/tests/test_gateway.py b/rootfs/scheduler/tests/test_gateway.py
index 72d00c2cf..385abb2fa 100644
--- a/rootfs/scheduler/tests/test_gateway.py
+++ b/rootfs/scheduler/tests/test_gateway.py
@@ -75,7 +75,7 @@ def create_http_route(self, name="test-gateway"):
             name,
             name,
             port=5000,
-            procfile_type="web",
+            ptype="web",
             rules={},
             parent_refs={},
         )
@@ -89,11 +89,6 @@ def test_patch_http_route(self):
                         "name": "tcp-echo-v1",
                         "port": 9000,
                         "weight": 10,
-                    },
-                    {
-                        "name": "tcp-echo-v1-canary",
-                        "port": 9000,
-                        "weight": 90,
                     }
                 ]
             }
@@ -102,7 +97,7 @@ def test_patch_http_route(self):
             "test-gateway",
             "test-gateway",
             port=8000,
-            procfile_type="cmd",
+            ptype="cmd",
             version=1,
             rules=rules,
             parent_refs={},
@@ -129,7 +124,7 @@ def create_tcp_route(self, name="test-tcp-route"):
             name,
             name,
             port=5000,
-            procfile_type="celery",
+            ptype="celery",
             rules={},
             parent_refs={},
         )
@@ -143,11 +138,6 @@ def test_patch_tcp_route(self):
                         "name": "tcp-echo-v1",
                         "port": 9000,
                         "weight": 10,
-                    },
-                    {
-                        "name": "tcp-echo-v1-canary",
-                        "port": 9000,
-                        "weight": 90,
                     }
                 ]
             }
@@ -156,7 +146,7 @@ def test_patch_tcp_route(self):
             "test-tcp-route",
             "test-tcp-route",
             port=8000,
-            procfile_type="cmd",
+            ptype="cmd",
             version=1,
             rules=rules,
             parent_refs={},
@@ -183,7 +173,7 @@ def create_udp_route(self, name="test-udp-route"):
             name,
             name,
             port=5000,
-            procfile_type="celery",
+            ptype="celery",
             rules={},
             parent_refs={}
         )
@@ -197,11 +187,6 @@ def test_patch_udp_route(self):
                         "name": "udp-echo-v1",
                         "port": 9000,
                         "weight": 10,
-                    },
-                    {
-                        "name": "udp-echo-v1-canary",
-                        "port": 9000,
-                        "weight": 90,
                     }
                 ]
             }
@@ -210,7 +195,7 @@ def test_patch_udp_route(self):
             "test-udp-route",
             "test-udp-route",
             port=8000,
-            procfile_type="cmd",
+            ptype="cmd",
             version=1,
             rules=rules,
             parent_refs={}
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
