Compare commits
111 commits
2025.04.14
...
main
Author | SHA1 | Date | |
---|---|---|---|
2f4e6a4494 | |||
d4f24c9166 | |||
![]() |
7c2f1b74d5 | ||
![]() |
d306cbe52b | ||
f38db4541d | |||
8016c7a5ad | |||
3e1d6d6947 | |||
8e12688597 | |||
![]() |
4df4bc36fa | ||
![]() |
b088f40563 | ||
bbbe390238 | |||
917dd31b57 | |||
b6fb21eb60 | |||
d31850ef90 | |||
![]() |
b3a59f243c | ||
![]() |
3844092231 | ||
![]() |
02abc2c60c | ||
![]() |
316fd17ae9 | ||
75f34b8174 | |||
4deb7ab58f | |||
e3af3ddfec | |||
1892d27475 | |||
de0cb1b885 | |||
3c36b89584 | |||
a37b0d4a13 | |||
fa6ac5334e | |||
cb7332f4e9 | |||
50a7f628e4 | |||
c3d8fd9f56 | |||
c19b73eb07 | |||
8566520f97 | |||
f480711f4e | |||
b146a6e59f | |||
d5a7133b23 | |||
![]() |
79a1c4dc45 | ||
![]() |
9ee826eb50 | ||
![]() |
891519e97b | ||
![]() |
fd8c21895e | ||
![]() |
1b3239db3e | ||
![]() |
0312b5d2cc | ||
![]() |
cc27d74482 | ||
![]() |
8dfbafb230 | ||
![]() |
68c9f75684 | ||
![]() |
87838a38c3 | ||
15370f9739 | |||
880b38ce3f | |||
4d5c8e3784 | |||
e0a1197a70 | |||
3976d2905b | |||
67a76e7f4c | |||
995ace7d97 | |||
45a1825b70 | |||
06efd09f60 | |||
52553796d3 | |||
378f10c992 | |||
313a8a5492 | |||
8edb059831 | |||
4ef2f9a31b | |||
a6a617f229 | |||
126ff35065 | |||
1dda974e11 | |||
5f38856dd9 | |||
eb73b35a5c | |||
d8cc90188e | |||
07393cfd61 | |||
4d8d276a9a | |||
a3186b835f | |||
d32ed1eb55 | |||
d1d1352cf4 | |||
3e466fb011 | |||
52aa6acfb6 | |||
3a8b9987b2 | |||
09ce5406ee | |||
c570275387 | |||
926c9441f2 | |||
83b44fd262 | |||
4bb52cda4f | |||
eb4d3f9556 | |||
fd3cb6a1d4 | |||
ba30fb0402 | |||
8a67e16a0b | |||
032596c0e4 | |||
f464483c7a | |||
51bcd620e1 | |||
857105b01f | |||
f65e6e0de0 | |||
9830eebcda | |||
f03940fe61 | |||
22178f32fd | |||
4d1215f976 | |||
e63171f3b5 | |||
a1ffdf565d | |||
ff5761ddc7 | |||
a5875cf2b9 | |||
aa73805cf6 | |||
9ddca7c0a4 | |||
d2ed55b606 | |||
b2d9004359 | |||
6160f48d61 | |||
c4f7c8df69 | |||
fa3eb7c4fc | |||
c8eaa99d38 | |||
2a359b50ef | |||
60b47ed6c8 | |||
40811cbc08 | |||
7afc4400b7 | |||
93916cdcbc | |||
6d34e3abdc | |||
912842bd82 | |||
c4522e31e8 | |||
0eb07feeef |
43 changed files with 2472 additions and 672 deletions
14
.env.example
14
.env.example
|
@ -47,3 +47,17 @@ SERVALA_DEFAULT_ORIGIN='1'
|
||||||
SERVALA_KEYCLOAK_CLIENT_ID='portal.servala.com'
|
SERVALA_KEYCLOAK_CLIENT_ID='portal.servala.com'
|
||||||
SERVALA_KEYCLOAK_CLIENT_SECRET=''
|
SERVALA_KEYCLOAK_CLIENT_SECRET=''
|
||||||
SERVALA_KEYCLOAK_SERVER_URL=''
|
SERVALA_KEYCLOAK_SERVER_URL=''
|
||||||
|
|
||||||
|
# S3 Storage settings (optional, for using S3 compatible storage for media files)
|
||||||
|
# If these are set, Django will use S3 for default file storage.
|
||||||
|
# Defaults are indicated if any.
|
||||||
|
# SERVALA_STORAGE_BUCKET_NAME=''
|
||||||
|
# SERVALA_S3_ENDPOINT_URL=''
|
||||||
|
# SERVALA_ACCESS_KEY_ID=''
|
||||||
|
# SERVALA_SECRET_ACCESS_KEY=''
|
||||||
|
# SERVALA_S3_REGION_NAME='eu-central-1'
|
||||||
|
# SERVALA_S3_ADDRESSING_STYLE='virtual'
|
||||||
|
# SERVALA_S3_SIGNATURE_VERSION='s3v4'
|
||||||
|
|
||||||
|
# Configuration for Sentry error reporting
|
||||||
|
SERVALA_SENTRY_DSN=''
|
|
@ -4,6 +4,13 @@ on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "*"
|
||||||
|
paths:
|
||||||
|
- "deployment/**"
|
||||||
|
- "docker/**"
|
||||||
|
- "src/**"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "uv.lock"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -44,7 +51,7 @@ jobs:
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
@ -80,7 +87,7 @@ jobs:
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.16
|
uses: docker://quay.io/appuio/oc:v4.18
|
||||||
with:
|
with:
|
||||||
entrypoint: /bin/bash
|
entrypoint: /bin/bash
|
||||||
args: |
|
args: |
|
||||||
|
@ -97,7 +104,7 @@ jobs:
|
||||||
OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }}
|
OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }}
|
||||||
|
|
||||||
- name: Verify deployment
|
- name: Verify deployment
|
||||||
uses: docker://quay.io/appuio/oc:v4.16
|
uses: docker://quay.io/appuio/oc:v4.18
|
||||||
with:
|
with:
|
||||||
entrypoint: /bin/bash
|
entrypoint: /bin/bash
|
||||||
args: |
|
args: |
|
||||||
|
|
|
@ -3,6 +3,13 @@ name: Build and Deploy Staging
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "deployment/**"
|
||||||
|
- "docker/**"
|
||||||
|
- "src/**"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "uv.lock"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -28,7 +35,7 @@ jobs:
|
||||||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
@ -49,7 +56,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.16
|
uses: docker://quay.io/appuio/oc:v4.18
|
||||||
with:
|
with:
|
||||||
entrypoint: /bin/bash
|
entrypoint: /bin/bash
|
||||||
args: |
|
args: |
|
||||||
|
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docs/Dockerfile
|
file: docs/Dockerfile
|
||||||
|
@ -52,7 +52,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.16
|
uses: docker://quay.io/appuio/oc:v4.18
|
||||||
with:
|
with:
|
||||||
entrypoint: /bin/bash
|
entrypoint: /bin/bash
|
||||||
args: |
|
args: |
|
||||||
|
|
33
.forgejo/workflows/renovate.yaml
Normal file
33
.forgejo/workflows/renovate.yaml
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
name: Renovate Dependency Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Renovate
|
||||||
|
uses: https://github.com/renovatebot/github-action@v42.0.4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
env:
|
||||||
|
LOG_LEVEL: info
|
||||||
|
RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }}
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_REPOSITORIES: ${{ github.repository }}
|
||||||
|
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||||
|
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate@servala.app.codey.ch>"
|
||||||
|
RENOVATE_USERNAME: renovate
|
||||||
|
RENOVATE_ENABLE_PYTHON_TOOL_VERSIONS: true
|
|
@ -2,6 +2,10 @@ name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "uv.lock"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -17,7 +21,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: https://github.com/astral-sh/setup-uv@v5
|
uses: https://github.com/astral-sh/setup-uv@v6
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run --env-file=.env.example pytest
|
run: uv run --env-file=.env.example pytest
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.12
|
3.13
|
||||||
|
|
|
@ -2,3 +2,4 @@ resources:
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- cronjob.yaml
|
- cronjob.yaml
|
||||||
|
- objectstorage.yaml
|
||||||
|
|
9
deployment/kustomize/base/portal/objectstorage.yaml
Normal file
9
deployment/kustomize/base/portal/objectstorage.yaml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
apiVersion: appcat.vshn.io/v1
|
||||||
|
kind: ObjectBucket
|
||||||
|
metadata:
|
||||||
|
name: portal-storage
|
||||||
|
spec:
|
||||||
|
parameters:
|
||||||
|
region: lpg
|
||||||
|
writeConnectionSecretToRef:
|
||||||
|
name: portal-storage-creds
|
|
@ -11,3 +11,4 @@ resources:
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
patches:
|
patches:
|
||||||
- path: portal-deployment.yaml
|
- path: portal-deployment.yaml
|
||||||
|
- path: objectstorage.yaml
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
apiVersion: appcat.vshn.io/v1
|
||||||
|
kind: ObjectBucket
|
||||||
|
metadata:
|
||||||
|
name: portal-storage
|
||||||
|
spec:
|
||||||
|
parameters:
|
||||||
|
bucketName: servala-portal-storage-production
|
|
@ -11,3 +11,4 @@ resources:
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
patches:
|
patches:
|
||||||
- path: portal-deployment.yaml
|
- path: portal-deployment.yaml
|
||||||
|
- path: objectstorage.yaml
|
||||||
|
|
7
deployment/kustomize/overlays/staging/objectstorage.yaml
Normal file
7
deployment/kustomize/overlays/staging/objectstorage.yaml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
apiVersion: appcat.vshn.io/v1
|
||||||
|
kind: ObjectBucket
|
||||||
|
metadata:
|
||||||
|
name: portal-storage
|
||||||
|
spec:
|
||||||
|
parameters:
|
||||||
|
bucketName: servala-portal-storage-staging
|
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
|
@ -0,0 +1,469 @@
|
||||||
|
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="981px" height="521px" viewBox="-0.5 -0.5 981 521" content="<mxfile><diagram id="iQWVNTns26_xaYTS_M6j" name="Page-1">7Vtbc+MmFP41nmkf6pGEbn7c2Gm3M+k2Hc9su49YQjKtLFyMb/vrCxboir2KLUvJNslDxOEi+L7DOYcjMgLT1eEXCtfL30iIkpFlhIcRmI0syzQ9m/8RkmMm8XyQCWKKQ9moEMzxVySFhpRucYg2lYaMkIThdVUYkDRFAavIIKVkX20WkaT61jWMUUMwD2DSlP6JQ7bMpL5jFPKPCMdL9WbTkDUrqBpLwWYJQ7IvicDjCEwpISx7Wh2mKBHgKVyyfj+fqc0nRlHK2nSwsg47mGzl2qYJ2YZc9EzJjqNMR2IE99+tmNHD44FsTjDkErkMdlTYULJNQySGN3n1fokZmq9hIGr3XBu4bMlWiazeIcowH/BDguOUyxaEMbLiFRFOkilJCD0NCiJH/Ao5SVlJnv1w+YZR8g8q1binn3yC4kXocBYkM4ee6ywiK8TokTeRHVzFllRXWxG9L8i3XClblolXvEOpcHE+dsEJf5C06CkCL6PoVPk/JCk3DhdIAv69SLKbJHEQKBFtnhOYcjIM8wINxrdp6AIjUMPIa2KUw1HGyO4AIqcFRNbgEAFnQIjcFhB9GhyiHJIhIPIay0ch982ySChbkpikMHkspA9VgIo2T4SsJSx/I8aOMtCAW0aqoHFg6PEv0X/sqOIXOdypMDtUSkdVOmCWdbMcWfyihuTPRS9RUJ2yBYpVXeaHg0C2NJCtJpmIQRoj1crS00hRAhneVYe/hRO/oba/rtYJWvH5IuGnFkctaU9wwePCCtBQuphAdKUa37PCYZhxijb4K1ycxhOorQlO2WkNzsPImV3SdBkVys55iFRB2Lu4A34yxsBQkB8rg7cGVw7+LOZdGlmNqrqQKNpwQuts5HNqRdCkQdAc0R0ORIiQiAAhxDv+GIvHH9A4Hgt7QzYspmj+x9OPqhV/TanhbUFFLXYIHeSHti5G8K0F6CpG8AyvwhnQGC5LY7jcDgyXCk9KJAxhyq6zLqalMS9uT+bFbAZOfdj83HZ77W33tY6iO1ayMKsPVppHx9yoGDMU4RQzTNJWFkb6COPz/OOnPqxNFCE3CHTWJvQmC8PoxtrYtWOjaWuOjeBe5gYMu2nuHe+YblP37RtV/9T1A6XwWGogA4uzHts2qiwDr5ZxqbW3/Ivt+UM2g2t9vWm/FeJ7NpYahenNVjrvnLTl5NaoopdN7Fh33sTNTEDhXH/n7oviNH6xaxWAiEUUKcJeQnuI/EjrbN3AR4uoo9RWzaxamqREnoDo3NkOk5V4/fvb1wSofl9Gt5mWeGGAOnuAcN77Ubif4NS3hwxOJ4Psl5Lu+6Ci/WPD+cYGOJWeud3lixWZqVp+7wVnxOu2ki6/19dOspqpizef4MtU8Oz24BrhqSNUNwm+ThN66r2v/HuTaw/4pUDl1175ByfPHRKjYbIEvQYh6sNjJQg5w0n3pvP9NN6eFKsvUpqfojs8Xak7Mt/P2coFA56t1EfxN7WDvD62kO5wdatduyp7Yhq1GwHqEsW59ImnEmL69jenTwa6JfBGdeZWs9uPzvjgzjpzKV3Qwik0kwXfr0OYOEM6hLO3GS6wJCN/GfbradB1inC8pbCcJ7pn5kddoDx/UfLc1coQbpb5Wztg2KonuCeaU4mpYdjpgGHQzDe8M9w1w8AekuFm/uKd4a4Ztt3+GObF4p8WMu9b/OsHePwP</diagram></mxfile>">
|
||||||
|
<defs/>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<rect x="410" y="420" width="260" height="100" rx="15" ry="15" fill="#f5f5f5" stroke="#666666" pointer-events="all" style="fill: light-dark(rgb(245, 245, 245), rgb(26, 26, 26)); stroke: light-dark(rgb(102, 102, 102), rgb(149, 149, 149));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe center; width: 258px; height: 1px; padding-top: 517px; margin-left: 411px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #333333; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#333333, #c1c1c1); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Cloud Provider "Exoscale"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="540" y="517" fill="#333333" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Cloud Provider "Exoscale"
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="0" y="420" width="380" height="100" rx="15" ry="15" fill="#f5f5f5" stroke="#666666" pointer-events="all" style="fill: light-dark(rgb(245, 245, 245), rgb(26, 26, 26)); stroke: light-dark(rgb(102, 102, 102), rgb(149, 149, 149));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe center; width: 378px; height: 1px; padding-top: 517px; margin-left: 1px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #333333; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#333333, #c1c1c1); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Cloud Provider "Cloudscale"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="190" y="517" fill="#333333" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Cloud Provider "Cloudscale"
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="20" y="440" width="100" height="40" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 460px; margin-left: 21px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Control Plane 1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="70" y="464" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Control Plane 1
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="140" y="440" width="100" height="40" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 460px; margin-left: 141px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Control Plane 2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="190" y="464" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Control Plane 2
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="260" y="440" width="100" height="40" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 460px; margin-left: 261px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Control Plane N
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="310" y="464" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Control Plane N
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 527 60 L 527 85 L 325 85 L 325 103.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 325 108.88 L 321.5 101.88 L 325 103.63 L 328.5 101.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 87px; margin-left: 436px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; ">
|
||||||
|
<div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">
|
||||||
|
Implemented by
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="436" y="90" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">
|
||||||
|
Implemented by
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="497" y="0" width="120" height="60" rx="9" ry="9" fill="#d5e8d4" stroke="#82b366" pointer-events="all" style="fill: light-dark(rgb(213, 232, 212), rgb(31, 47, 30)); stroke: light-dark(rgb(130, 179, 102), rgb(68, 110, 44));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 30px; margin-left: 498px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Service
|
||||||
|
<div>
|
||||||
|
(e.g. PostgreSQL)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="557" y="34" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Service...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 325 170 L 325 205 L 190 205 L 190 233.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 190 238.88 L 186.5 231.88 L 190 233.63 L 193.5 231.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 382.5 170 L 382.5 205 L 540 205 L 540 233.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 540 238.88 L 536.5 231.88 L 540 233.63 L 543.5 231.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="210" y="110" width="230" height="60" rx="9" ry="9" fill="#ffe6cc" stroke="#d79b00" pointer-events="all" style="fill: light-dark(rgb(255, 230, 204), rgb(54, 33, 10)); stroke: light-dark(rgb(215, 155, 0), rgb(153, 101, 0));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 228px; height: 1px; padding-top: 140px; margin-left: 211px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Service Definition
|
||||||
|
<div>
|
||||||
|
(e.g. PostgreSQL by VSHN)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="325" y="144" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Service Definition...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 190 300 L 190 340 L 70 340 L 70 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 70 438.88 L 66.5 431.88 L 70 433.63 L 73.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 190 300 L 190 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 190 438.88 L 186.5 431.88 L 190 433.63 L 193.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 190 300 L 190 340 L 310 340 L 310 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 310 438.88 L 306.5 431.88 L 310 433.63 L 313.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="70" y="240" width="240" height="60" rx="9" ry="9" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" style="fill: light-dark(rgb(218, 232, 252), rgb(29, 41, 59)); stroke: light-dark(rgb(108, 142, 191), rgb(92, 121, 163));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 238px; height: 1px; padding-top: 270px; margin-left: 71px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Service Offering
|
||||||
|
<div>
|
||||||
|
(e.g. PostgreSQL by VSHN at Cloudscale)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="190" y="274" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Service Offering...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 745 170 L 745 205 L 860 205 L 860 233.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 860 238.88 L 856.5 231.88 L 860 233.63 L 863.5 231.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="630" y="110" width="230" height="60" rx="9" ry="9" fill="#ffe6cc" stroke="#d79b00" pointer-events="all" style="fill: light-dark(rgb(255, 230, 204), rgb(54, 33, 10)); stroke: light-dark(rgb(215, 155, 0), rgb(153, 101, 0));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 228px; height: 1px; padding-top: 140px; margin-left: 631px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Service Definition
|
||||||
|
<div>
|
||||||
|
(e.g. DBaaS PostgreSQL)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="745" y="144" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Service Definition...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 587 60 L 587 85 L 764.1 85 L 764.09 106.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 764.09 111.88 L 760.59 104.88 L 764.09 106.63 L 767.59 104.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 85px; margin-left: 686px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; ">
|
||||||
|
<div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">
|
||||||
|
Implemented by
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="686" y="88" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">
|
||||||
|
Implemented by
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="430" y="440" width="100" height="40" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 460px; margin-left: 431px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Control Plane 1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="480" y="464" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Control Plane 1
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="550" y="440" width="100" height="40" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 460px; margin-left: 551px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Control Plane 2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="600" y="464" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Control Plane 2
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 540 300 L 540 370 L 480 370 L 480 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 480 438.88 L 476.5 431.88 L 480 433.63 L 483.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 540 300 L 540 370 L 600 370 L 600 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 600 438.88 L 596.5 431.88 L 600 433.63 L 603.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="420" y="240" width="240" height="60" rx="9" ry="9" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" style="fill: light-dark(rgb(218, 232, 252), rgb(29, 41, 59)); stroke: light-dark(rgb(108, 142, 191), rgb(92, 121, 163));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 238px; height: 1px; padding-top: 270px; margin-left: 421px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Service Offering
|
||||||
|
<div>
|
||||||
|
(e.g. PostgreSQL by VSHN at Exoscale)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="540" y="274" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Service Offering...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 860 300 L 860 320 L 505 320 L 505 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 505 438.88 L 501.5 431.88 L 505 433.63 L 508.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 860 300 L 860 320 L 625 320 L 625 433.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 625 438.88 L 621.5 431.88 L 625 433.63 L 628.5 431.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="740" y="240" width="240" height="60" rx="9" ry="9" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" style="fill: light-dark(rgb(218, 232, 252), rgb(29, 41, 59)); stroke: light-dark(rgb(108, 142, 191), rgb(92, 121, 163));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 238px; height: 1px; padding-top: 270px; margin-left: 741px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Service Offering
|
||||||
|
<div>
|
||||||
|
(e.g. DBaaS PostgreSQL at Exoscale)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="860" y="274" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Service Offering...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="10" y="360" width="110" height="50" rx="7.5" ry="7.5" fill="#f5f5f5" stroke="#666666" stroke-dasharray="3 3" pointer-events="all" style="fill: light-dark(rgb(245, 245, 245), rgb(26, 26, 26)); stroke: light-dark(rgb(102, 102, 102), rgb(149, 149, 149));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 108px; height: 1px; padding-top: 385px; margin-left: 11px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #333333; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#333333, #c1c1c1); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
ServiceOffering
|
||||||
|
<div>
|
||||||
|
ControlPlane
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="65" y="389" fill="#333333" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
ServiceOffering...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="130" y="360" width="110" height="50" rx="7.5" ry="7.5" fill="#f5f5f5" stroke="#666666" stroke-dasharray="3 3" pointer-events="all" style="fill: light-dark(rgb(245, 245, 245), rgb(26, 26, 26)); stroke: light-dark(rgb(102, 102, 102), rgb(149, 149, 149));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 108px; height: 1px; padding-top: 385px; margin-left: 131px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #333333; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#333333, #c1c1c1); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
ServiceOffering
|
||||||
|
<div>
|
||||||
|
ControlPlane
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="185" y="389" fill="#333333" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
ServiceOffering...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="250" y="360" width="110" height="50" rx="7.5" ry="7.5" fill="#f5f5f5" stroke="#666666" stroke-dasharray="3 3" pointer-events="all" style="fill: light-dark(rgb(245, 245, 245), rgb(26, 26, 26)); stroke: light-dark(rgb(102, 102, 102), rgb(149, 149, 149));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 108px; height: 1px; padding-top: 385px; margin-left: 251px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #333333; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#333333, #c1c1c1); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
ServiceOffering
|
||||||
|
<div>
|
||||||
|
ControlPlane
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="305" y="389" fill="#333333" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
ServiceOffering...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<switch>
|
||||||
|
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
|
||||||
|
<a transform="translate(0,-5)" xlink:href="https://www.drawio.com/doc/faq/svg-export-text-problems" target="_blank">
|
||||||
|
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
|
||||||
|
Text is not SVG - cannot display
|
||||||
|
</text>
|
||||||
|
</a>
|
||||||
|
</switch>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 38 KiB |
|
@ -0,0 +1,226 @@
|
||||||
|
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="741px" height="211px" viewBox="-0.5 -0.5 741 211" content="<mxfile><diagram id="oCg05mIILSXJTHsOPe0H" name="Page-1">5VjbctowEP0aHpvxHecxQJp0knbokDZN34QtjCbCYmRxy9d3hSVsWaYhhaS3J2tX0lo6e/Zid/z+bH3F0Xz6kaWYdjwnXXf8QcfzXLcbwENqNqWmG/ulIuMkVYsqxYg8YaV0lHZBUlwYCwVjVJC5qUxYnuNEGDrEOVuZyyaMmm+dowxbilGCqK29J6mYlto4dCr9NSbZVL/ZddTMDOnFSlFMUcpWNZV/2fH7nDFRjmbrPqYSPI1Lue/9ntndwTjOxSEbglidQ2z05XAKd1ViznJ49KZiRkFyYQh2+eYbCM5ZqMUHKWphsDakjZbWRNS2gfSgLcK42iQFvce+jLpfwRY8Ucf1lP8Rz7BaFZYqeZHaNgXAFWYzDCeDBRxTJMjSdCpS3Mh26yr4YKAQbEczikobS0QXyupNXIDiYvihFedbNIbQMABGlGQ5jBO4NOagWGIuCHDvQk3MSJpKGz2OC/KExlt7Eq45I7nYnj7sdcJBK4Da39IoXndagkUZNPhooKZ2vXPOPDdU5jaGpYNxVcaH8uA1y7twUWa1Q7UJNpkU4OumY3ZnPMhXcfRS5isGuzX+VmxuZ3AVLUasVKGzJ1paCfwroRAHb0T9uPtfwOm9EZyelUju8RgUQ8YFohbUnC3yFKcKztWUCDyao+1FVlCCTeQnhNI+o4xv9/opwvEkAX0hOHvEtZkoifF48rNEbKWRvenCC8yYdnWxXFUFNNBrprXiGYbHoxlYaF7f3Q1HL2TsXhTqDPH2cK1OLP9IErWnzfMGwN3YtFCeQG06KnX6FpxfCihVTTSht5nL4WJGLxLB6rVsW/eGrCCCMFnTBJu3VLoxE4LNTCewhaAkB5bqzk7G95Rx8sRyGRnlKtqwvyumVnU9AbXj55nttxA7co4ndmh5oj8aggIO7HyXBIbWo+NFFF7ZG4OHokyOADwIdWlnSJFcdMpsEuI4DdqySeyNfWiQTpNNokaH0LUxd1uzySlAd/ag7v3jqIfu70T9xQ3G3/Cl4rSWi5PXBr+RoyBJHVQcLEPNMg5931nDVHm/U9SZqGsF2h/2OaVJefznlHMWBPq3yMZI7kd/TfnmltN8PdkNqm5NnUFPJ7+ULHX2g2IsMo5Hn2/1JLyiNr+3e0iRQAV0D/j5pHiCFNf1G8X83E5x569UzGO7Tf06uv4k+Z4kUBxEC6w3eJNQhh5fG9QWgls4Hw6q+2qgglj9PCt5Xf2C9C9/AA==</diagram></mxfile>">
|
||||||
|
<defs/>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M 380 95 L 255.7 157.15" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 251 159.5 L 255.7 153.24 L 255.7 157.15 L 258.83 159.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 147px; margin-left: 230px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; ">
|
||||||
|
<div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">
|
||||||
|
K8s API
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="230" y="150" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">
|
||||||
|
K8s API
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 600 67.5 L 644.91 33.82" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 649.11 30.67 L 645.61 37.67 L 644.91 33.82 L 641.41 32.07 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 600 67.5 L 645.15 105.88" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 649.15 109.28 L 641.55 107.41 L 645.15 105.88 L 646.08 102.08 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="160" y="40" width="440" height="55" rx="8.25" ry="8.25" fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" style="fill: light-dark(rgb(218, 232, 252), rgb(29, 41, 59)); stroke: light-dark(rgb(108, 142, 191), rgb(92, 121, 163));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 438px; height: 1px; padding-top: 68px; margin-left: 161px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Web Portal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="380" y="71" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Web Portal
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 30 69.9 L 153.63 69.05" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 158.88 69.01 L 151.91 72.56 L 153.63 69.05 L 151.86 65.56 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 69px; margin-left: 95px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; ">
|
||||||
|
<div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">
|
||||||
|
HTTPS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="95" y="73" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">
|
||||||
|
HTTPS
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<ellipse cx="15" cy="47.5" rx="7.5" ry="7.5" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 15 55 L 15 80 M 15 60 L 0 60 M 15 60 L 30 60 M 15 80 L 0 100 M 15 80 L 30 100" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 37px; margin-left: 15px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: nowrap; ">
|
||||||
|
User
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="15" y="37" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
User
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="180" y="160" width="140" height="50" rx="7.5" ry="7.5" fill="#d5e8d4" stroke="#82b366" pointer-events="all" style="fill: light-dark(rgb(213, 232, 212), rgb(31, 47, 30)); stroke: light-dark(rgb(130, 179, 102), rgb(68, 110, 44));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 138px; height: 1px; padding-top: 185px; margin-left: 181px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
CSP 1 Zone A
|
||||||
|
<br/>
|
||||||
|
Control Plane
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="250" y="189" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
CSP 1 Zone A...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="430" y="160" width="140" height="50" rx="7.5" ry="7.5" fill="#d5e8d4" stroke="#82b366" pointer-events="all" style="fill: light-dark(rgb(213, 232, 212), rgb(31, 47, 30)); stroke: light-dark(rgb(130, 179, 102), rgb(68, 110, 44));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 138px; height: 1px; padding-top: 185px; margin-left: 431px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
CSP 2 Zone A
|
||||||
|
<br/>
|
||||||
|
Control Plane
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="500" y="189" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
CSP 2 Zone A...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 380 95 L 494.4 156.97" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 499.02 159.47 L 491.19 159.21 L 494.4 156.97 L 494.53 153.06 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 138px; margin-left: 457px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; ">
|
||||||
|
<div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">
|
||||||
|
K8s API
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="457" y="142" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">
|
||||||
|
K8s API
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 650 88 C 650 77.33 740 77.33 740 88 L 740 132 C 740 142.67 650 142.67 650 132 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 650 88 C 650 96 740 96 740 88 M 650 92 C 650 100 740 100 740 92 M 650 96 C 650 104 740 104 740 96" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 88px; height: 1px; padding-top: 120px; margin-left: 651px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
Portal DB
|
||||||
|
<div>
|
||||||
|
PostgreSQL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="695" y="124" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
Portal DB...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M 650 8 C 650 -2.67 740 -2.67 740 8 L 740 52 C 740 62.67 650 62.67 650 52 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
<path d="M 650 8 C 650 16 740 16 740 8 M 650 12 C 650 20 740 20 740 12 M 650 16 C 650 24 740 24 740 16" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-0.5 -0.5)">
|
||||||
|
<switch>
|
||||||
|
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 88px; height: 1px; padding-top: 40px; margin-left: 651px;">
|
||||||
|
<div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; ">
|
||||||
|
<div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
|
||||||
|
VSHN Account
|
||||||
|
<div>
|
||||||
|
Keycloak
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="695" y="44" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">
|
||||||
|
VSHN Account...
|
||||||
|
</text>
|
||||||
|
</switch>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<switch>
|
||||||
|
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
|
||||||
|
<a transform="translate(0,-5)" xlink:href="https://www.drawio.com/doc/faq/svg-export-text-problems" target="_blank">
|
||||||
|
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
|
||||||
|
Text is not SVG - cannot display
|
||||||
|
</text>
|
||||||
|
</a>
|
||||||
|
</switch>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 19 KiB |
|
@ -2,6 +2,10 @@
|
||||||
* xref:terminology.adoc[]
|
* xref:terminology.adoc[]
|
||||||
|
|
||||||
* xref:web-portal.adoc[]
|
* xref:web-portal.adoc[]
|
||||||
|
** xref:web-portal-admin.adoc[Admin]
|
||||||
|
** xref:web-portal-controlplanes.adoc[Control-Planes]
|
||||||
|
|
||||||
|
* xref:web-portal-planning.adoc[]
|
||||||
** xref:user-stories.adoc[]
|
** xref:user-stories.adoc[]
|
||||||
** xref:organizations.adoc[]
|
** xref:organizations.adoc[]
|
||||||
** xref:authentication.adoc[]
|
** xref:authentication.adoc[]
|
||||||
|
@ -9,7 +13,6 @@
|
||||||
** xref:service-catalog.adoc[]
|
** xref:service-catalog.adoc[]
|
||||||
** xref:service-instances.adoc[]
|
** xref:service-instances.adoc[]
|
||||||
** xref:api.adoc[]
|
** xref:api.adoc[]
|
||||||
** xref:database-diagram.adoc[]
|
|
||||||
|
|
||||||
* Cloud Providers
|
* Cloud Providers
|
||||||
** xref:exoscale-osb.adoc[]
|
** xref:exoscale-osb.adoc[]
|
|
@ -408,7 +408,7 @@ POST /orgs/:uuid/usage <1>
|
||||||
|
|
||||||
* https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html[APPUiO Control API Architecture^]
|
* https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html[APPUiO Control API Architecture^]
|
||||||
* https://kb.vshn.ch/appuio-cloud/references/architecture/invitations.html[APPUiO Invitations]
|
* https://kb.vshn.ch/appuio-cloud/references/architecture/invitations.html[APPUiO Invitations]
|
||||||
* https://github.com/vshn/crossplane-service-broker[Crossplane Service Broker (Code)^] - xref:how-tos/crossplane_service_broker/overview.adoc[Crossplane Service Broker (Docs)]
|
* https://github.com/vshn/crossplane-service-broker[Crossplane Service Broker (Code)^] - https://kb.vshn.ch/app-catalog/csp/spks/crossplane/overview.html[Crossplane Service Broker (Docs)^]
|
||||||
* https://github.com/vshn/swisscom-service-broker[Swisscom Service Broker^]
|
* https://github.com/vshn/swisscom-service-broker[Swisscom Service Broker^]
|
||||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^]
|
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^]
|
||||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^]
|
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^]
|
||||||
|
|
59
docs/modules/ROOT/pages/web-portal-admin.adoc
Normal file
59
docs/modules/ROOT/pages/web-portal-admin.adoc
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
= Web Portal Admin
|
||||||
|
|
||||||
|
The administration of the web portal happens with Django Admin.
|
||||||
|
|
||||||
|
[TIP]
|
||||||
|
====
|
||||||
|
* Production: https://portal.servala.com/admin[portal.servala.com^]
|
||||||
|
* Staging: https://staging.portal.servala.com/admin[staging.portal.servala.com^]
|
||||||
|
====
|
||||||
|
|
||||||
|
== Service Catalog and Control-Plane Models
|
||||||
|
|
||||||
|
image::portal-service-relations.drawio.svg[]
|
||||||
|
|
||||||
|
Service::
|
||||||
|
The software service, top-level, categorized. +
|
||||||
|
_Examples_: PostgreSQL, Redis, GitLab. +
|
||||||
|
Admin: https://staging.portal.servala.com/admin/core/service/[staging^], https://portal.servala.com/admin/core/service/[prod^]
|
||||||
|
|
||||||
|
Service Definition::
|
||||||
|
A correlation between a specific managed service offering with the API definition on the control-planes. It tells the Portal which Kubernetes API implements a managed service. +
|
||||||
|
_Example_: "Forgejo by VSHN" is implemented by GVK `vshn.appcat.vshn.io/v1/VSHNForgejo` on the control-planes. +
|
||||||
|
Admin: https://staging.portal.servala.com/admin/core/servicedefinition/[staging^], https://portal.servala.com/admin/core/servicedefinition/[prod^]
|
||||||
|
|
||||||
|
Service Offering::
|
||||||
|
The service offering is the glue which connects a service with a service provider, the control-planes with the service definitions and plan information. It essentially tells the Portal which managed service is available on which control-plane with which specific configuration. It relates to "ControlPlane CRD" which is a correlation between "Service Offering", "Control Plane" and "Service Definition".
|
||||||
|
_Example_: "Forgejo at Hetzner Cloud" which makes the Service "Forgejo" available at Hetzner Cloud and through "ControlPlane CRDs" it defines which service definition is available in which control-plane at Hetzner Cloud. It also specifies plans with features, pricing and terms. +
|
||||||
|
Admin: https://staging.portal.servala.com/admin/core/serviceoffering/[staging^], https://portal.servala.com/admin/core/serviceoffering/[prod^]
|
||||||
|
|
||||||
|
== Models
|
||||||
|
|
||||||
|
In addition to the models described in <<Service Catalog and Control-Plane Models>>, the following core models exist:
|
||||||
|
|
||||||
|
Cloud Providers::
|
||||||
|
Cloud providers where service instances can be provisioned at.
|
||||||
|
|
||||||
|
Control Planes::
|
||||||
|
Connections to Kubernetes API servers. Each control-plane represents a zone at a cloud provider.
|
||||||
|
|
||||||
|
Organizations::
|
||||||
|
The main multi-tenant object.
|
||||||
|
|
||||||
|
Organization Memberships::
|
||||||
|
Defines organization memberships including the roles in an organization.
|
||||||
|
|
||||||
|
Organization Origins::
|
||||||
|
The origin of an organization. Where the organization is coming from, influences e.g. access to control-planes or service offerings.
|
||||||
|
|
||||||
|
Billing Entities::
|
||||||
|
Billing contacts for Organizations - this is not further implemented yet.
|
||||||
|
|
||||||
|
Plans::
|
||||||
|
Plans for service offerings.
|
||||||
|
|
||||||
|
Service Categories::
|
||||||
|
Allows to categorize services.
|
||||||
|
|
||||||
|
Service Instances::
|
||||||
|
Service instances provisioned on control-planes.
|
30
docs/modules/ROOT/pages/web-portal-controlplanes.adoc
Normal file
30
docs/modules/ROOT/pages/web-portal-controlplanes.adoc
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
= Web Portal Control-Planes
|
||||||
|
|
||||||
|
Each control-plane represents a zone at a cloud provider. It's a dedicated Kubernetes API endpoint running the Servala control-plane.
|
||||||
|
|
||||||
|
To register a control-plane, a service account with appropriate permissions is required on the Kubernetes API server.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# Create service account
|
||||||
|
kubectl -n kube-system create sa servala-portal
|
||||||
|
|
||||||
|
# Create long-lived token for service account
|
||||||
|
kubectl -n kube-system apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: servala-portal-token
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/service-account.name: servala-portal
|
||||||
|
type: kubernetes.io/service-account-token
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Grant access
|
||||||
|
kubectl create clusterrolebinding servala-portal-admin --clusterrole=cluster-admin --serviceaccount=kube-system:servala-portal
|
||||||
|
|
||||||
|
# Retrieve token
|
||||||
|
kubectl ksd -n kube-system get secret servala-portal-token -o yaml
|
||||||
|
----
|
64
docs/modules/ROOT/pages/web-portal-planning.adoc
Normal file
64
docs/modules/ROOT/pages/web-portal-planning.adoc
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
= Web Portal Planning
|
||||||
|
|
||||||
|
image::web-portal-arch.drawio.svg[]
|
||||||
|
|
||||||
|
The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala.
|
||||||
|
Servala is the brand for the product formerly known as "VSHN Application Marketplace".
|
||||||
|
|
||||||
|
It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way.
|
||||||
|
|
||||||
|
The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view.
|
||||||
|
|
||||||
|
External resources to read about it:
|
||||||
|
|
||||||
|
* http://vshn.ch/marketplace[vshn.ch Website^]
|
||||||
|
* https://products.vshn.ch/marketplace/index.html[VSHN products site^]
|
||||||
|
|
||||||
|
The source code can be found on the https://servala.app.codey.ch/servala/servala-portal[Servala Codey instance^].
|
||||||
|
|
||||||
|
== Technology Stack
|
||||||
|
|
||||||
|
We choose:
|
||||||
|
|
||||||
|
* Python https://docs.djangoproject.com/en/dev/internals/release-process/#term-Long-term-support-release[Django LTS^]
|
||||||
|
* PostgreSQL as database backend
|
||||||
|
* https://gunicorn.org/[Gunicorn^] Python WSGI HTTP Server
|
||||||
|
* https://caddyserver.com/[Caddy^] for serving static files and WSGI, or https://whitenoise.readthedocs.io/en/latest/[WhiteNoise^] (TBD)
|
||||||
|
* https://docs.astral.sh/uv/[Astral uv^] for Python project, dependency and build management
|
||||||
|
* https://htmx.org/[htmx] for the dynamic part in the frontend
|
||||||
|
* https://getbootstrap.com/[Bootstrap 5] for styling the frontend
|
||||||
|
|
||||||
|
A complete reasoning for this stack is available in https://vshnwiki.atlassian.net/wiki/spaces/VSHNPM/pages/402718747/Self-Service+Marketplace+Web+Application[our wiki^] (internal page).
|
||||||
|
|
||||||
|
== Development Paradigms
|
||||||
|
|
||||||
|
Keep usage of third-party dependencies low::
|
||||||
|
Every external dependency adds a burden on the maintenance of the application.
|
||||||
|
Adding a dependency must be done with care:
|
||||||
|
* Is the dependency well maintained and adopted in the ecosystem?
|
||||||
|
* Could we do it without the dependency? If not, why?
|
||||||
|
* What happens if the dependency is abandoned?
|
||||||
|
* Document the reason for each dependency, why it has been chosen and why we can't live without it.
|
||||||
|
|
||||||
|
Graceful degradation::
|
||||||
|
The application will connect to various upstream APIs which we can't control.
|
||||||
|
Should issues arise with one of these APIs, gracefully degrade the feature set and inform accordingly ("This service is currently not available - we're working on it").
|
||||||
|
Never must the application crash because and upstream API not being reachable, has slow response time or react in an undefined way.
|
||||||
|
|
||||||
|
Use database and caches wisely::
|
||||||
|
External systems like databases, caches or queues add additional complexity and burden to the application operations.
|
||||||
|
Every additional system must be chosen carefully and the reasoning documented.
|
||||||
|
Alternatives must be considered and documented.
|
||||||
|
|
||||||
|
Business logic vs. views::
|
||||||
|
Whenever possible, split business logic from views.
|
||||||
|
This allows to progress the application in the future to allow for different views (For example APIs or other alternative frontends).
|
||||||
|
|
||||||
|
Django specifics::
|
||||||
|
* We use class based views by default, exceptions can be made
|
||||||
|
* Dynamic configuration happens via environment variables
|
||||||
|
* Different environments (dev / test / prod) must be clearly separated inside the application
|
||||||
|
|
||||||
|
Testing::
|
||||||
|
Business functionality must be https://docs.djangoproject.com/en/5.1/topics/testing/[tested^] with code.
|
||||||
|
We preferrably use https://docs.pytest.org/[pytest^].
|
|
@ -1,64 +1,17 @@
|
||||||
= Web Portal
|
= Web Portal
|
||||||
|
|
||||||
image::web-portal-arch.drawio.svg[]
|
image::web-portal-arch-current.drawio.svg[]
|
||||||
|
|
||||||
|
[TIP]
|
||||||
|
====
|
||||||
|
* Production: https://portal.servala.com/[portal.servala.com^]
|
||||||
|
* Staging: https://staging.portal.servala.com/[staging.portal.servala.com^]
|
||||||
|
====
|
||||||
|
|
||||||
The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala.
|
The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala.
|
||||||
Servala is the brand for the product formerly known as "VSHN Application Marketplace".
|
|
||||||
|
|
||||||
It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way.
|
It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way.
|
||||||
|
|
||||||
The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view.
|
The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view.
|
||||||
|
|
||||||
External resources to read about it:
|
The source code can be found on the https://servala.app.codey.ch/servala/servala-portal[Servala Codey instance^].
|
||||||
|
|
||||||
* http://vshn.ch/marketplace[vshn.ch Website^]
|
|
||||||
* https://products.vshn.ch/marketplace/index.html[VSHN products site^]
|
|
||||||
|
|
||||||
The source code can be found on the https://servala.app.codey.ch/servala/servala-portal[Servala Codey instance^].
|
|
||||||
|
|
||||||
== Technology Stack
|
|
||||||
|
|
||||||
We choose:
|
|
||||||
|
|
||||||
* Python https://docs.djangoproject.com/en/dev/internals/release-process/#term-Long-term-support-release[Django LTS^]
|
|
||||||
* PostgreSQL as database backend
|
|
||||||
* https://gunicorn.org/[Gunicorn^] Python WSGI HTTP Server
|
|
||||||
* https://caddyserver.com/[Caddy^] for serving static files and WSGI, or https://whitenoise.readthedocs.io/en/latest/[WhiteNoise^] (TBD)
|
|
||||||
* https://docs.astral.sh/uv/[Astral uv^] for Python project, dependency and build management
|
|
||||||
* https://htmx.org/[htmx] for the dynamic part in the frontend
|
|
||||||
* https://getbootstrap.com/[Bootstrap 5] for styling the frontend
|
|
||||||
|
|
||||||
A complete reasoning for this stack is available in https://vshnwiki.atlassian.net/wiki/spaces/VSHNPM/pages/402718747/Self-Service+Marketplace+Web+Application[our wiki^] (internal page).
|
|
||||||
|
|
||||||
== Development Paradigms
|
|
||||||
|
|
||||||
Keep usage of third-party dependencies low::
|
|
||||||
Every external dependency adds a burden on the maintenance of the application.
|
|
||||||
Adding a dependency must be done with care:
|
|
||||||
* Is the dependency well maintained and adopted in the ecosystem?
|
|
||||||
* Could we do it without the dependency? If not, why?
|
|
||||||
* What happens if the dependency is abandoned?
|
|
||||||
* Document the reason for each dependency, why it has been chosen and why we can't live without it.
|
|
||||||
|
|
||||||
Graceful degradation::
|
|
||||||
The application will connect to various upstream APIs which we can't control.
|
|
||||||
Should issues arise with one of these APIs, gracefully degrade the feature set and inform accordingly ("This service is currently not available - we're working on it").
|
|
||||||
Never must the application crash because and upstream API not being reachable, has slow response time or react in an undefined way.
|
|
||||||
|
|
||||||
Use database and caches wisely::
|
|
||||||
External systems like databases, caches or queues add additional complexity and burden to the application operations.
|
|
||||||
Every additional system must be chosen carefully and the reasoning documented.
|
|
||||||
Alternatives must be considered and documented.
|
|
||||||
|
|
||||||
Business logic vs. views::
|
|
||||||
Whenever possible, split business logic from views.
|
|
||||||
This allows to progress the application in the future to allow for different views (For example APIs or other alternative frontends).
|
|
||||||
|
|
||||||
Django specifics::
|
|
||||||
* We use class based views by default, exceptions can be made
|
|
||||||
* Dynamic configuration happens via environment variables
|
|
||||||
* Different environments (dev / test / prod) must be clearly separated inside the application
|
|
||||||
|
|
||||||
Testing::
|
|
||||||
Business functionality must be https://docs.djangoproject.com/en/5.1/topics/testing/[tested^] with code.
|
|
||||||
We preferrably use https://docs.pytest.org/[pytest^].
|
|
|
@ -3,22 +3,24 @@ name = "servala"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "Servala portal server and frontend"
|
description = "Servala portal server and frontend"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.13.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2-cffi>=23.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"cryptography>=44.0.2",
|
"cryptography>=45.0.3",
|
||||||
"django==5.2b1",
|
"django==5.2.2",
|
||||||
"django-allauth>=65.5.0",
|
"django-allauth>=65.9.0",
|
||||||
"django-fernet-encrypted-fields>=0.3.0",
|
"django-fernet-encrypted-fields>=0.3.0",
|
||||||
"django-scopes>=2.0.0",
|
"django-scopes>=2.0.0",
|
||||||
|
"django-storages>=1.14.6",
|
||||||
"django-template-partials>=24.4",
|
"django-template-partials>=24.4",
|
||||||
"jsonschema>=4.23.0",
|
"jsonschema>=4.24.0",
|
||||||
"kubernetes>=32.0.1",
|
"kubernetes>=32.0.1",
|
||||||
"pillow>=11.1.0",
|
"pillow>=11.2.1",
|
||||||
"psycopg2-binary>=2.9.10",
|
"psycopg2-binary>=2.9.10",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"rules>=3.5",
|
"rules>=3.5",
|
||||||
|
"sentry-sdk[django]>=2.29.1",
|
||||||
"urlman>=2.0.2",
|
"urlman>=2.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -26,15 +28,15 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
"black>=25.1.0",
|
"black>=25.1.0",
|
||||||
"bumpver>=2024.1130",
|
"bumpver>=2024.1130",
|
||||||
"coverage>=7.7.0",
|
"coverage>=7.8.2",
|
||||||
"djlint>=1.36.4",
|
"djlint>=1.36.4",
|
||||||
"flake8>=7.1.2",
|
"flake8>=7.2.0",
|
||||||
"flake8-bugbear>=24.12.12",
|
"flake8-bugbear>=24.12.12",
|
||||||
"flake8-pyproject>=1.2.3",
|
"flake8-pyproject>=1.2.3",
|
||||||
"isort>=6.0.1",
|
"isort>=6.0.1",
|
||||||
"pytest>=8.3.5",
|
"pytest>=8.4.0",
|
||||||
"pytest-cov>=6.0.0",
|
"pytest-cov>=6.1.1",
|
||||||
"pytest-django>=4.10.0",
|
"pytest-django>=4.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
|
@ -44,7 +46,7 @@ known_first_party = "servala"
|
||||||
[tool.flake8]
|
[tool.flake8]
|
||||||
max-line-length = 160
|
max-line-length = 160
|
||||||
exclude = ".venv"
|
exclude = ".venv"
|
||||||
ignore = "E203"
|
ignore = "E203,W503"
|
||||||
|
|
||||||
[tool.djlint]
|
[tool.djlint]
|
||||||
extend_exclude = "src/servala/static/mazer"
|
extend_exclude = "src/servala/static/mazer"
|
||||||
|
|
43
renovate.json
Normal file
43
renovate.json
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"github-actions"
|
||||||
|
],
|
||||||
|
"matchFileNames": [
|
||||||
|
".forgejo/workflows/*.yml",
|
||||||
|
".forgejo/workflows/*.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"pep621"
|
||||||
|
],
|
||||||
|
"rangeStrategy": "bump"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": [
|
||||||
|
"python"
|
||||||
|
],
|
||||||
|
"matchManagers": [
|
||||||
|
"dockerfile"
|
||||||
|
],
|
||||||
|
"versioning": "docker"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"labels": [
|
||||||
|
"dependencies"
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": [
|
||||||
|
"before 5am on monday"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"branchConcurrentLimit": 10
|
||||||
|
}
|
|
@ -126,6 +126,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("name", "description")
|
search_fields = ("name", "description")
|
||||||
autocomplete_fields = ("cloud_provider",)
|
autocomplete_fields = ("cloud_provider",)
|
||||||
actions = ["test_kubernetes_connection"]
|
actions = ["test_kubernetes_connection"]
|
||||||
|
ordering = ("name",)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
|
|
|
@ -9,6 +9,18 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from servala.core.models import ServiceInstance
|
from servala.core.models import ServiceInstance
|
||||||
|
|
||||||
|
|
||||||
|
class CRDModel(models.Model):
|
||||||
|
"""Base class for all virtual CRD models"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
if spec := kwargs.pop("spec", None):
|
||||||
|
kwargs.update(unnest_data({"spec": spec}))
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
def duplicate_field(field_name, model):
|
def duplicate_field(field_name, model):
|
||||||
# Get the field from the model
|
# Get the field from the model
|
||||||
field = model._meta.get_field(field_name)
|
field = model._meta.get_field(field_name)
|
||||||
|
@ -47,7 +59,7 @@ def generate_django_model(schema, group, version, kind):
|
||||||
|
|
||||||
# create the model class
|
# create the model class
|
||||||
model_name = kind
|
model_name = kind
|
||||||
model_class = type(model_name, (models.Model,), model_fields)
|
model_class = type(model_name, (CRDModel,), model_fields)
|
||||||
return model_class
|
return model_class
|
||||||
|
|
||||||
|
|
||||||
|
@ -138,6 +150,21 @@ def get_django_field(
|
||||||
return models.CharField(max_length=255, **kwargs)
|
return models.CharField(max_length=255, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def unnest_data(data):
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
def _flatten_dict(d, parent_key=""):
|
||||||
|
for key, value in d.items():
|
||||||
|
new_key = f"{parent_key}.{key}" if parent_key else key
|
||||||
|
if isinstance(value, dict):
|
||||||
|
_flatten_dict(value, new_key)
|
||||||
|
else:
|
||||||
|
result[new_key] = value
|
||||||
|
|
||||||
|
_flatten_dict(data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class CrdModelFormMixin:
|
class CrdModelFormMixin:
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -146,6 +173,11 @@ class CrdModelFormMixin:
|
||||||
for field in ("organization", "context"):
|
for field in ("organization", "context"):
|
||||||
self.fields[field].widget = forms.HiddenInput()
|
self.fields[field].widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
self.fields["name"].disabled = True
|
||||||
|
self.fields["name"].help_text = _("Name cannot be changed after creation.")
|
||||||
|
self.fields["name"].widget = forms.HiddenInput()
|
||||||
|
|
||||||
def strip_title(self, field_name, label):
|
def strip_title(self, field_name, label):
|
||||||
field = self.fields[field_name]
|
field = self.fields[field_name]
|
||||||
if field and field.label.startswith(label):
|
if field and field.label.startswith(label):
|
||||||
|
@ -156,12 +188,20 @@ class CrdModelFormMixin:
|
||||||
|
|
||||||
# General fieldset for non-spec fields
|
# General fieldset for non-spec fields
|
||||||
general_fields = [
|
general_fields = [
|
||||||
field for field in self.fields if not field.startswith("spec.")
|
field_name
|
||||||
|
for field_name, field in self.fields.items()
|
||||||
|
if not field_name.startswith("spec.")
|
||||||
]
|
]
|
||||||
if general_fields:
|
if general_fields:
|
||||||
fieldsets.append(
|
fieldset = {"title": "General", "fields": general_fields, "fieldsets": []}
|
||||||
{"title": "General", "fields": general_fields, "fieldsets": []}
|
if all(
|
||||||
)
|
[
|
||||||
|
isinstance(self.fields[field].widget, forms.HiddenInput)
|
||||||
|
for field in general_fields
|
||||||
|
]
|
||||||
|
):
|
||||||
|
fieldset["hidden"] = True
|
||||||
|
fieldsets.append(fieldset)
|
||||||
|
|
||||||
# Process spec fields
|
# Process spec fields
|
||||||
others = []
|
others = []
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import kubernetes
|
import kubernetes
|
||||||
|
@ -6,7 +7,8 @@ import urlman
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import IntegrityError, models
|
from django.db import IntegrityError, models, transaction
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from encrypted_fields.fields import EncryptedJSONField
|
from encrypted_fields.fields import EncryptedJSONField
|
||||||
|
@ -188,6 +190,10 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
||||||
def get_kubernetes_client(self):
|
def get_kubernetes_client(self):
|
||||||
return kubernetes.client.ApiClient(self.kubernetes_config)
|
return kubernetes.client.ApiClient(self.kubernetes_config)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def custom_objects_api(self):
|
||||||
|
return client.CustomObjectsApi(self.get_kubernetes_client())
|
||||||
|
|
||||||
def test_connection(self):
|
def test_connection(self):
|
||||||
if not self.api_credentials:
|
if not self.api_credentials:
|
||||||
return False, _("No API credentials provided")
|
return False, _("No API credentials provided")
|
||||||
|
@ -355,11 +361,32 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
|
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def group(self):
|
||||||
|
return self.service_definition.api_definition["group"]
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def version(self):
|
||||||
|
return self.service_definition.api_definition["version"]
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def kind(self):
|
||||||
|
return self.service_definition.api_definition["kind"]
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def kind_plural(self):
|
||||||
|
if (
|
||||||
|
hasattr(self.resource_definition, "status")
|
||||||
|
and hasattr(self.resource_definition.status, "accepted_names")
|
||||||
|
and self.resource_definition.status.accepted_names
|
||||||
|
):
|
||||||
|
return self.resource_definition.status.accepted_names.plural
|
||||||
|
if self.kind.endswith("s"):
|
||||||
|
return self.kind.lower()
|
||||||
|
return f"{self.kind.lower()}s"
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def resource_definition(self):
|
def resource_definition(self):
|
||||||
kind = self.service_definition.api_definition["kind"]
|
|
||||||
group = self.service_definition.api_definition["group"]
|
|
||||||
version = self.service_definition.api_definition["version"]
|
|
||||||
client = self.control_plane.get_kubernetes_client()
|
client = self.control_plane.get_kubernetes_client()
|
||||||
|
|
||||||
extensions_api = kubernetes.client.ApiextensionsV1Api(client)
|
extensions_api = kubernetes.client.ApiextensionsV1Api(client)
|
||||||
|
@ -368,10 +395,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
for crd in crds.items:
|
for crd in crds.items:
|
||||||
if matching_crd:
|
if matching_crd:
|
||||||
break
|
break
|
||||||
if crd.spec.group == group:
|
if crd.spec.group == self.group:
|
||||||
for served_version in crd.spec.versions:
|
for served_version in crd.spec.versions:
|
||||||
if served_version.name == version and served_version.served:
|
if served_version.name == self.version and served_version.served:
|
||||||
if crd.spec.names.kind == kind:
|
if crd.spec.names.kind == self.kind:
|
||||||
matching_crd = crd
|
matching_crd = crd
|
||||||
break
|
break
|
||||||
return matching_crd
|
return matching_crd
|
||||||
|
@ -382,9 +409,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
if result := cache.get(cache_key):
|
if result := cache.get(cache_key):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
version = self.service_definition.api_definition["version"]
|
if not self.resource_definition:
|
||||||
|
return
|
||||||
|
|
||||||
for v in self.resource_definition.spec.versions:
|
for v in self.resource_definition.spec.versions:
|
||||||
if v.name == version:
|
if v.name == self.version:
|
||||||
|
if not v.schema or not v.schema.open_apiv3_schema:
|
||||||
|
return
|
||||||
result = v.schema.open_apiv3_schema.to_dict()
|
result = v.schema.open_apiv3_schema.to_dict()
|
||||||
timeout_seconds = 60 * 60 * 24
|
timeout_seconds = 60 * 60 * 24
|
||||||
cache.set(cache_key, result, timeout=timeout_seconds)
|
cache.set(cache_key, result, timeout=timeout_seconds)
|
||||||
|
@ -394,10 +425,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
def django_model(self):
|
def django_model(self):
|
||||||
from servala.core.crd import generate_django_model
|
from servala.core.crd import generate_django_model
|
||||||
|
|
||||||
|
if not self.resource_schema:
|
||||||
|
return
|
||||||
|
|
||||||
kwargs = {
|
kwargs = {
|
||||||
key: value
|
"group": self.group,
|
||||||
for key, value in self.service_definition.api_definition.items()
|
"version": self.version,
|
||||||
if key in ("group", "version", "kind")
|
"kind": self.kind,
|
||||||
}
|
}
|
||||||
return generate_django_model(self.resource_schema, **kwargs)
|
return generate_django_model(self.resource_schema, **kwargs)
|
||||||
|
|
||||||
|
@ -405,6 +439,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
def model_form_class(self):
|
def model_form_class(self):
|
||||||
from servala.core.crd import generate_model_form_class
|
from servala.core.crd import generate_model_form_class
|
||||||
|
|
||||||
|
if not self.django_model:
|
||||||
|
return
|
||||||
return generate_model_form_class(self.django_model)
|
return generate_model_form_class(self.django_model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -497,6 +533,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
base = "{self.organization.urls.instances}{self.name}/"
|
base = "{self.organization.urls.instances}{self.name}/"
|
||||||
|
update = "{base}update/"
|
||||||
|
delete = "{base}delete/"
|
||||||
|
|
||||||
|
def _clear_kubernetes_caches(self):
|
||||||
|
"""Clears cached properties that depend on Kubernetes state."""
|
||||||
|
attrs = self.__dict__.keys()
|
||||||
|
for key in (
|
||||||
|
"kubernetes_object",
|
||||||
|
"spec",
|
||||||
|
"status_conditions",
|
||||||
|
"connection_credentials",
|
||||||
|
):
|
||||||
|
if key in attrs:
|
||||||
|
delattr(self, key)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||||
|
@ -517,12 +567,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
group = context.service_definition.api_definition["group"]
|
|
||||||
version = context.service_definition.api_definition["version"]
|
|
||||||
kind = context.service_definition.api_definition["kind"]
|
|
||||||
create_data = {
|
create_data = {
|
||||||
"apiVersion": f"{group}/{version}",
|
"apiVersion": f"{context.group}/{context.version}",
|
||||||
"kind": kind,
|
"kind": context.kind,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": name,
|
"name": name,
|
||||||
"namespace": organization.namespace,
|
"namespace": organization.namespace,
|
||||||
|
@ -531,18 +578,12 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
}
|
}
|
||||||
if label := context.control_plane.required_label:
|
if label := context.control_plane.required_label:
|
||||||
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
||||||
api_instance = client.CustomObjectsApi(
|
api_instance = context.control_plane.custom_objects_api
|
||||||
context.control_plane.get_kubernetes_client()
|
|
||||||
)
|
|
||||||
plural = kind.lower()
|
|
||||||
if not plural.endswith("s"):
|
|
||||||
plural = f"{plural}s"
|
|
||||||
|
|
||||||
api_instance.create_namespaced_custom_object(
|
api_instance.create_namespaced_custom_object(
|
||||||
group=group,
|
group=context.group,
|
||||||
version=version,
|
version=context.version,
|
||||||
namespace=organization.namespace,
|
namespace=organization.namespace,
|
||||||
plural=plural,
|
plural=context.kind_plural,
|
||||||
body=create_data,
|
body=create_data,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -556,3 +597,194 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
|
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
|
||||||
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
def update_spec(self, spec_data, updated_by):
|
||||||
|
try:
|
||||||
|
api_instance = self.context.control_plane.custom_objects_api
|
||||||
|
patch_body = {"spec": spec_data}
|
||||||
|
|
||||||
|
api_instance.patch_namespaced_custom_object(
|
||||||
|
group=self.context.group,
|
||||||
|
version=self.context.version,
|
||||||
|
namespace=self.organization.namespace,
|
||||||
|
plural=self.context.kind_plural,
|
||||||
|
name=self.name,
|
||||||
|
body=patch_body,
|
||||||
|
)
|
||||||
|
self._clear_kubernetes_caches()
|
||||||
|
self.save() # Updates updated_at timestamp
|
||||||
|
except ApiException as e:
|
||||||
|
if e.status == 404:
|
||||||
|
raise ValidationError(
|
||||||
|
_(
|
||||||
|
"Service instance not found in Kubernetes. It may have been deleted externally."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
error_body = json.loads(e.body)
|
||||||
|
reason = error_body.get("message", str(e))
|
||||||
|
raise ValidationError(
|
||||||
|
_("Kubernetes API error updating instance: {error}").format(
|
||||||
|
error=reason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Kubernetes API error updating instance: {error}").format(
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Error updating instance: {error}").format(error=str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_instance(self, user):
|
||||||
|
"""
|
||||||
|
Soft deletes the instance in Django and initiates deletion of the
|
||||||
|
corresponding Kubernetes custom resource.
|
||||||
|
"""
|
||||||
|
if self.is_deleted:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.spec.get("parameters", {})
|
||||||
|
.get("security", {})
|
||||||
|
.get("deletionProtection")
|
||||||
|
):
|
||||||
|
spec = copy.copy(self.spec)
|
||||||
|
spec["parameters"]["security"]["deletionProtection"] = False
|
||||||
|
self.update_spec(spec, user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_instance = self.context.control_plane.custom_objects_api
|
||||||
|
api_instance.delete_namespaced_custom_object(
|
||||||
|
group=self.context.group,
|
||||||
|
version=self.context.version,
|
||||||
|
namespace=self.organization.namespace,
|
||||||
|
plural=self.context.kind_plural,
|
||||||
|
name=self.name,
|
||||||
|
body=client.V1DeleteOptions(),
|
||||||
|
)
|
||||||
|
except ApiException as e:
|
||||||
|
if e.status != 404:
|
||||||
|
# 404 is fine, the object was deleted already.
|
||||||
|
raise
|
||||||
|
self.is_deleted = True
|
||||||
|
self.deleted_at = timezone.now()
|
||||||
|
self.deleted_by = user
|
||||||
|
self.save(
|
||||||
|
update_fields=["is_deleted", "deleted_at", "deleted_by", "updated_at"]
|
||||||
|
)
|
||||||
|
self._clear_kubernetes_caches()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def kubernetes_object(self):
|
||||||
|
"""Fetch the Kubernetes custom resource object"""
|
||||||
|
if self.is_deleted:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
api_instance = client.CustomObjectsApi(
|
||||||
|
self.context.control_plane.get_kubernetes_client()
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_instance.get_namespaced_custom_object(
|
||||||
|
group=self.context.group,
|
||||||
|
version=self.context.version,
|
||||||
|
namespace=self.organization.namespace,
|
||||||
|
plural=self.context.kind_plural,
|
||||||
|
name=self.name,
|
||||||
|
)
|
||||||
|
except ApiException as e:
|
||||||
|
if e.status == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def spec(self):
|
||||||
|
if not self.kubernetes_object:
|
||||||
|
return {}
|
||||||
|
if not (spec := self.kubernetes_object.get("spec")):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Remove fields that shouldn't be displayed
|
||||||
|
spec = spec.copy()
|
||||||
|
spec.pop("resourceRef", None)
|
||||||
|
spec.pop("writeConnectionSecretToRef", None)
|
||||||
|
return spec
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def spec_object(self):
|
||||||
|
"""Dynamically generated CRD object."""
|
||||||
|
if not self.context.django_model:
|
||||||
|
return
|
||||||
|
return self.context.django_model(
|
||||||
|
name=self.name,
|
||||||
|
organization=self.organization,
|
||||||
|
context=self.context,
|
||||||
|
spec=self.spec,
|
||||||
|
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
||||||
|
# and b) it’s not a normal database object. This allows us to treat e.g. update
|
||||||
|
# forms differently from create forms.
|
||||||
|
pk=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def status_conditions(self):
|
||||||
|
if not self.kubernetes_object:
|
||||||
|
return []
|
||||||
|
if not (status := self.kubernetes_object.get("status")):
|
||||||
|
return []
|
||||||
|
return status.get("conditions") or []
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def connection_credentials(self):
|
||||||
|
"""
|
||||||
|
Get connection credentials directly from the resource's writeConnectionSecretToRef
|
||||||
|
after checking that secret conditions are available.
|
||||||
|
"""
|
||||||
|
if not self.kubernetes_object:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Check if secrets are available based on conditions
|
||||||
|
secrets_available = any(
|
||||||
|
[
|
||||||
|
condition.get("type") == "Ready" and condition.get("status") == "True"
|
||||||
|
for condition in self.status_conditions
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if not secrets_available:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
spec = self.kubernetes_object.get("spec")
|
||||||
|
if not (secret_ref := spec.get("writeConnectionSecretToRef")):
|
||||||
|
return {}
|
||||||
|
if not (secret_name := secret_ref.get("name")):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the secret data
|
||||||
|
v1 = kubernetes.client.CoreV1Api(
|
||||||
|
self.context.control_plane.get_kubernetes_client()
|
||||||
|
)
|
||||||
|
secret = v1.read_namespaced_secret(
|
||||||
|
name=secret_name, namespace=self.organization.namespace
|
||||||
|
)
|
||||||
|
|
||||||
|
# Secret data is base64 encoded
|
||||||
|
credentials = {}
|
||||||
|
if hasattr(secret, "data") and secret.data:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
for key, value in secret.data.items():
|
||||||
|
try:
|
||||||
|
credentials[key] = base64.b64decode(value).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
credentials[key] = f"<binary data: {len(value)} bytes>"
|
||||||
|
|
||||||
|
return credentials
|
||||||
|
except ApiException as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
|
@ -6,6 +7,7 @@ from servala.core.models import (
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
|
ServiceInstance,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -17,13 +19,17 @@ class ServiceFilterForm(forms.Form):
|
||||||
cloud_provider = forms.ModelChoiceField(
|
cloud_provider = forms.ModelChoiceField(
|
||||||
queryset=CloudProvider.objects.all(), required=False
|
queryset=CloudProvider.objects.all(), required=False
|
||||||
)
|
)
|
||||||
q = forms.CharField(required=False)
|
q = forms.CharField(label=_("Search"), required=False)
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
if category := self.cleaned_data.get("category"):
|
if category := self.cleaned_data.get("category"):
|
||||||
queryset = queryset.filter(category=category)
|
queryset = queryset.filter(category=category)
|
||||||
if cloud_provider := self.cleaned_data.get("cloud_provider"):
|
if cloud_provider := self.cleaned_data.get("cloud_provider"):
|
||||||
queryset = queryset.filter(offerings__provider=cloud_provider)
|
queryset = queryset.filter(offerings__provider=cloud_provider)
|
||||||
|
if search := self.cleaned_data.get("q"):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=search) | Q(category__name__icontains=search)
|
||||||
|
)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,6 +43,8 @@ class ControlPlaneSelectForm(forms.Form):
|
||||||
def __init__(self, *args, planes=None, **kwargs):
|
def __init__(self, *args, planes=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["control_plane"].queryset = planes
|
self.fields["control_plane"].queryset = planes
|
||||||
|
if planes and planes.count() == 1:
|
||||||
|
self.fields["control_plane"].initial = planes.first()
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceFilterForm(forms.Form):
|
class ServiceInstanceFilterForm(forms.Form):
|
||||||
|
@ -68,21 +76,50 @@ class ServiceInstanceFilterForm(forms.Form):
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
if self.is_valid():
|
if self.is_valid():
|
||||||
data = self.cleaned_data
|
data = self.cleaned_data
|
||||||
if data["name"]:
|
if data.get("name"):
|
||||||
queryset = queryset.filter(name__icontains=data["name"])
|
queryset = queryset.filter(name__icontains=data["name"])
|
||||||
if data["service"]:
|
if data.get("service"):
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
context__service_definition__service=data["service"]
|
context__service_definition__service=data["service"]
|
||||||
)
|
)
|
||||||
if data["provider"]:
|
if data.get("provider"):
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
context__service_offering__provider=data["provider"]
|
context__service_offering__provider=data["provider"]
|
||||||
)
|
)
|
||||||
if data["control_plane"]:
|
if data.get("control_plane"):
|
||||||
queryset = queryset.filter(context__control_plane=data["control_plane"])
|
queryset = queryset.filter(context__control_plane=data["control_plane"])
|
||||||
if data["status"]:
|
status = data.get("status")
|
||||||
if data["status"] == "active":
|
if status == "active":
|
||||||
queryset = queryset.filter(is_deleted=False)
|
queryset = queryset.filter(is_deleted=False)
|
||||||
else:
|
elif status == "deleted":
|
||||||
queryset = queryset.filter(is_deleted=True)
|
queryset = queryset.filter(is_deleted=True)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstanceDeleteForm(forms.ModelForm):
|
||||||
|
name = forms.CharField(
|
||||||
|
label=_("Instance Name"),
|
||||||
|
max_length=63,
|
||||||
|
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["initial"] = {"name": ""}
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["name"].help_text = _(
|
||||||
|
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>"
|
||||||
|
).format(instance_name=self.instance.name)
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
entered_name = self.cleaned_data.get("name")
|
||||||
|
if entered_name != self.instance.name:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_(
|
||||||
|
"The entered name does not match the instance name. Deletion not confirmed."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entered_name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ServiceInstance
|
||||||
|
fields = ("name",)
|
||||||
|
|
|
@ -25,9 +25,13 @@
|
||||||
<div class="content-wrapper container">
|
<div class="content-wrapper container">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h3>
|
<h3>
|
||||||
{% block page_title %}
|
<span>
|
||||||
Dashboard
|
{% block page_title %}
|
||||||
{% endblock page_title %}
|
Dashboard
|
||||||
|
{% endblock page_title %}
|
||||||
|
</span>
|
||||||
|
{% block page_title_extra %}
|
||||||
|
{% endblock page_title_extra %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% if form.non_field_errors or form.errors %}
|
{% if form.non_field_errors or form.errors %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger form-errors" role="alert">
|
||||||
<div>
|
<div>
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
{% if form.non_field_errors|length > 1 %}
|
{% if form.non_field_errors|length > 1 %}
|
||||||
|
|
|
@ -28,38 +28,42 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% for offering in service.offerings.all %}
|
<div class="row">
|
||||||
<div class="card col-6 col-lg-3 col-md-4">
|
{% for offering in service.offerings.all %}
|
||||||
<div class="card-header d-flex align-items-center">
|
<div class="col-6 col-lg-3 col-md-4">
|
||||||
{% if offering.provider.logo %}
|
<div class="card">
|
||||||
<img src="{{ offering.provider.logo.url }}"
|
<div class="card-header d-flex align-items-center">
|
||||||
alt="{{ offering.provider.name }}"
|
{% if offering.provider.logo %}
|
||||||
class="me-3"
|
<img src="{{ offering.provider.logo.url }}"
|
||||||
style="max-width: 48px;
|
alt="{{ offering.provider.name }}"
|
||||||
max-height: 48px">
|
class="me-3"
|
||||||
{% endif %}
|
style="max-width: 48px;
|
||||||
<div class="d-flex flex-column">
|
max-height: 48px">
|
||||||
<h4 class="mb-0">{{ offering.provider.name }}</h4>
|
{% endif %}
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="mb-0">{{ offering.provider.name }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if offering.description %}
|
||||||
|
<p class="card-text">{{ offering.description }}</p>
|
||||||
|
{% elif offering.provider.description %}
|
||||||
|
<p class="card-text">{{ offering.provider.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between">
|
||||||
|
<span></span>
|
||||||
|
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
{% empty %}
|
||||||
{% if offering.description %}
|
<div class="card">
|
||||||
<p class="card-text">{{ offering.description }}</p>
|
<div class="card-body">
|
||||||
{% elif offering.provider.description %}
|
<p>{% translate "No offerings found." %}</p>
|
||||||
<p class="card-text">{{ offering.provider.description }}</p>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between">
|
{% endfor %}
|
||||||
<span></span>
|
</div>
|
||||||
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<p>{% translate "No offerings found." %}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
{% load i18n %}
|
||||||
|
<form method="post"
|
||||||
|
action="{{ view.request.path }}"
|
||||||
|
hx-post="{{ view.request.path }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteInstanceModalLabel">
|
||||||
|
{% blocktranslate with instance_name=instance.name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %}
|
||||||
|
</h5>
|
||||||
|
<button type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body hide-form-errors">
|
||||||
|
<p>{% translate "Do you really want to delete this service instance? This action cannot be undone." %}</p>
|
||||||
|
{{ form }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Cancel" %}</button>
|
||||||
|
<button type="submit" class="btn btn-danger">{% translate "Confirm and Delete Instance" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
|
@ -1,17 +1,34 @@
|
||||||
{% extends "frontend/base.html" %}
|
{% extends "frontend/base.html" %}
|
||||||
{% load i18n static %}
|
{% load i18n static pprint_filters %}
|
||||||
{% block html_title %}
|
{% block html_title %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
{{ instance.name }}
|
{{ instance.name }}
|
||||||
{% endblock page_title %}
|
{% endblock page_title %}
|
||||||
{% endblock html_title %}
|
{% endblock html_title %}
|
||||||
|
{% block page_title_extra %}
|
||||||
|
<div>
|
||||||
|
{% if has_change_permission and not instance.is_deleted %}
|
||||||
|
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_delete_permission and not instance.is_deleted %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-danger me-1 mb-1"
|
||||||
|
hx-get="{{ instance.urls.delete }}"
|
||||||
|
hx-target="#deleteInstanceModalContent"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteInstanceModal">{% translate "Delete" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock page_title_extra %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="card">
|
<div class="row">
|
||||||
<div class="card-body">
|
<div class="col-12 col-md-5">
|
||||||
<div class="row">
|
<div class="card">
|
||||||
<div class="col-md-6">
|
<div class="card-header">
|
||||||
<h5>{% translate "Details" %}</h5>
|
<h4>{% translate "Details" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
<dl class="row">
|
<dl class="row">
|
||||||
<dt class="col-sm-4">{% translate "Service" %}</dt>
|
<dt class="col-sm-4">{% translate "Service" %}</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
|
@ -27,7 +44,7 @@
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-4">{% translate "Created By" %}</dt>
|
<dt class="col-sm-4">{% translate "Created By" %}</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{{ instance.created_by }}
|
{{ instance.created_by|default:"-" }}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-4">{% translate "Created At" %}</dt>
|
<dt class="col-sm-4">{% translate "Created At" %}</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
|
@ -40,7 +57,14 @@
|
||||||
<dt class="col-sm-4">{% translate "Status" %}</dt>
|
<dt class="col-sm-4">{% translate "Status" %}</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% if instance.is_deleted %}
|
{% if instance.is_deleted %}
|
||||||
<span class="badge text-bg-secondary">{% translate "Deleted" %}</span>
|
<span class="badge text-bg-danger">{% translate "Deleted" %}</span>
|
||||||
|
{% if instance.deleted_at %}
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
{% blocktranslate with date=instance.deleted_at|date:"SHORT_DATETIME_FORMAT" user=instance.deleted_by|default:_("system") %}
|
||||||
|
On {{ date }} by {{ user }}
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge text-bg-success">{% translate "Active" %}</span>
|
<span class="badge text-bg-success">{% translate "Active" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -49,6 +73,181 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not instance.is_deleted and instance.status_conditions %}
|
||||||
|
<div class="col-12 col-md-7">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>{% translate "Status" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% translate "Type" %}</th>
|
||||||
|
<th>{% translate "Status" %}</th>
|
||||||
|
<th>{% translate "Last Transition Time" %}</th>
|
||||||
|
<th>{% translate "Reason" %}</th>
|
||||||
|
<th>{% translate "Message" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for condition in instance.status_conditions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ condition.type }}</td>
|
||||||
|
<td>
|
||||||
|
{% if condition.status == "True" %}
|
||||||
|
<span class="badge text-bg-success">True</span>
|
||||||
|
{% elif condition.status == "False" %}
|
||||||
|
<span class="badge text-bg-danger">False</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge text-bg-secondary">{{ condition.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||||
|
<td>{{ condition.reason|default:"-" }}</td>
|
||||||
|
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not instance.is_deleted and instance.spec and spec_fieldsets %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>{% translate "Specification" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
{% for fieldset in spec_fieldsets %}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a href="#spec-tab-{{ forloop.counter }}"
|
||||||
|
class="nav-link {% if forloop.first %}active{% endif %}"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
role="tab">{{ fieldset.title }}</a>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="nav-item ms-2">{% translate "No specification details available." %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="tab-content pt-3">
|
||||||
|
{% for fieldset in spec_fieldsets %}
|
||||||
|
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
|
||||||
|
id="spec-tab-{{ forloop.counter }}"
|
||||||
|
role="tabpanel">
|
||||||
|
<!-- Main Fields -->
|
||||||
|
<dl class="row">
|
||||||
|
{% for field in fieldset.fields %}
|
||||||
|
<dt class="col-sm-3">{{ field.label }}</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
|
||||||
|
<pre>{{ field.value|pprint }}</pre>
|
||||||
|
{% else %}
|
||||||
|
{{ field.value|default:"-" }}
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
<!-- Nested Fieldsets -->
|
||||||
|
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
|
||||||
|
<h5>{{ sub_fieldset.title }}</h5>
|
||||||
|
<dl class="row">
|
||||||
|
{% for field in sub_fieldset.fields %}
|
||||||
|
<dt class="col-sm-3">{{ field.label }}</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
|
||||||
|
<pre>{{ field.value|pprint }}</pre>
|
||||||
|
{% else %}
|
||||||
|
{{ field.value|default:"-" }}
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p>{% translate "No specification details to display." %}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if instance.connection_credentials %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>{% translate "Connection Credentials" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% translate "Name" %}</th>
|
||||||
|
<th>{% translate "Value" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in instance.connection_credentials.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key }}</td>
|
||||||
|
<td>
|
||||||
|
{% if key == "error" %}
|
||||||
|
<span class="text-danger">{{ value }}</span>
|
||||||
|
{% else %}
|
||||||
|
<code>{{ value }}</code>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade"
|
||||||
|
id="deleteInstanceModal"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteInstanceModalLabel"
|
||||||
|
aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content" id="deleteInstanceModalContent">
|
||||||
|
{# Content will be loaded here by HTMX #}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteInstanceModalLabel">{% translate "Confirm Deletion" %}</h5>
|
||||||
|
<button type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">{% translate "Loading..." %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Cancel" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
{% extends "frontend/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load partials %}
|
||||||
|
{% block html_title %}
|
||||||
|
{% block page_title %}
|
||||||
|
{% block title %}
|
||||||
|
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock page_title %}
|
||||||
|
{% endblock html_title %}
|
||||||
|
{% block page_title_extra %}
|
||||||
|
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
|
||||||
|
{% endblock page_title_extra %}
|
||||||
|
{% partialdef service-form %}
|
||||||
|
{% if form %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if form_error %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/tabbed_fieldset_form.html" with form=form %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endpartialdef %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="card">
|
||||||
|
{% if not form %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="service-form">{% partial service-form %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock content %}
|
|
@ -16,36 +16,40 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% for service in services %}
|
<div class="row">
|
||||||
<div class="card col-6 col-lg-3 col-md-4">
|
{% for service in services %}
|
||||||
<div class="card-header d-flex align-items-center">
|
<div class="col-6 col-lg-3 col-md-4">
|
||||||
{% if service.logo %}
|
<div class="card">
|
||||||
<img src="{{ service.logo.url }}"
|
<div class="card-header d-flex align-items-center">
|
||||||
alt="{{ service.name }}"
|
{% if service.logo %}
|
||||||
class="me-3"
|
<img src="{{ service.logo.url }}"
|
||||||
style="max-width: 48px;
|
alt="{{ service.name }}"
|
||||||
max-height: 48px">
|
class="me-3"
|
||||||
{% endif %}
|
style="max-width: 48px;
|
||||||
<div class="d-flex flex-column">
|
max-height: 48px">
|
||||||
<h4 class="mb-0">{{ service.name }}</h4>
|
{% endif %}
|
||||||
<small class="text-muted">{{ service.category }}</small>
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="mb-0">{{ service.name }}</h4>
|
||||||
|
<small class="text-muted">{{ service.category }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between">
|
||||||
|
<span></span>
|
||||||
|
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
{% empty %}
|
||||||
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p>{% translate "No services found." %}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between">
|
{% endfor %}
|
||||||
<span></span>
|
</div>
|
||||||
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<p>{% translate "No services found." %}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
</section>
|
||||||
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
@ -6,23 +6,25 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
{% for fieldset in form.get_fieldsets %}
|
{% for fieldset in form.get_fieldsets %}
|
||||||
<li class="nav-item" role="presentation">
|
{% if not fieldset.hidden %}
|
||||||
<button class="nav-link {% if forloop.first %}active{% endif %}"
|
<li class="nav-item" role="presentation">
|
||||||
id="{{ fieldset.title|slugify }}-tab"
|
<button class="nav-link {% if forloop.first %}active{% endif %}"
|
||||||
data-bs-toggle="tab"
|
id="{{ fieldset.title|slugify }}-tab"
|
||||||
data-bs-target="#{{ fieldset.title|slugify }}"
|
data-bs-toggle="tab"
|
||||||
type="button"
|
data-bs-target="#{{ fieldset.title|slugify }}"
|
||||||
role="tab"
|
type="button"
|
||||||
aria-controls="{{ fieldset.title|slugify }}"
|
role="tab"
|
||||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
aria-controls="{{ fieldset.title|slugify }}"
|
||||||
{{ fieldset.title }}
|
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
</button>
|
{{ fieldset.title }}
|
||||||
</li>
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="tab-content" id="myTabContent">
|
<div class="tab-content" id="myTabContent">
|
||||||
{% for fieldset in form.get_fieldsets %}
|
{% for fieldset in form.get_fieldsets %}
|
||||||
<div class="tab-pane fade my-2 {% if forloop.first %}show active{% endif %}"
|
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||||
id="{{ fieldset.title|slugify }}"
|
id="{{ fieldset.title|slugify }}"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="{{ fieldset.title|slugify }}-tab">
|
aria-labelledby="{{ fieldset.title|slugify }}-tab">
|
||||||
|
|
12
src/servala/frontend/templatetags/pprint_filters.py
Normal file
12
src/servala/frontend/templatetags/pprint_filters.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def pprint(value):
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(value, indent=2)
|
||||||
|
return value
|
|
@ -50,6 +50,16 @@ urlpatterns = [
|
||||||
views.ServiceInstanceDetailView.as_view(),
|
views.ServiceInstanceDetailView.as_view(),
|
||||||
name="organization.instance",
|
name="organization.instance",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"instances/<slug:slug>/update/",
|
||||||
|
views.ServiceInstanceUpdateView.as_view(),
|
||||||
|
name="organization.instance.update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"instances/<slug:slug>/delete/",
|
||||||
|
views.ServiceInstanceDeleteView.as_view(),
|
||||||
|
name="organization.instance.delete",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -7,8 +7,10 @@ from .organization import (
|
||||||
)
|
)
|
||||||
from .service import (
|
from .service import (
|
||||||
ServiceDetailView,
|
ServiceDetailView,
|
||||||
|
ServiceInstanceDeleteView,
|
||||||
ServiceInstanceDetailView,
|
ServiceInstanceDetailView,
|
||||||
ServiceInstanceListView,
|
ServiceInstanceListView,
|
||||||
|
ServiceInstanceUpdateView,
|
||||||
ServiceListView,
|
ServiceListView,
|
||||||
ServiceOfferingDetailView,
|
ServiceOfferingDetailView,
|
||||||
)
|
)
|
||||||
|
@ -20,8 +22,10 @@ __all__ = [
|
||||||
"OrganizationDashboardView",
|
"OrganizationDashboardView",
|
||||||
"OrganizationUpdateView",
|
"OrganizationUpdateView",
|
||||||
"ServiceDetailView",
|
"ServiceDetailView",
|
||||||
|
"ServiceInstanceDeleteView",
|
||||||
"ServiceInstanceDetailView",
|
"ServiceInstanceDetailView",
|
||||||
"ServiceInstanceListView",
|
"ServiceInstanceListView",
|
||||||
|
"ServiceInstanceUpdateView",
|
||||||
"ServiceListView",
|
"ServiceListView",
|
||||||
"ServiceOfferingDetailView",
|
"ServiceOfferingDetailView",
|
||||||
"ProfileView",
|
"ProfileView",
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.views.generic import DetailView, ListView
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import DetailView, ListView, UpdateView
|
||||||
|
|
||||||
|
from servala.core.crd import deslugify
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
ControlPlaneCRD,
|
ControlPlaneCRD,
|
||||||
Service,
|
Service,
|
||||||
|
@ -13,9 +16,14 @@ from servala.core.models import (
|
||||||
from servala.frontend.forms.service import (
|
from servala.frontend.forms.service import (
|
||||||
ControlPlaneSelectForm,
|
ControlPlaneSelectForm,
|
||||||
ServiceFilterForm,
|
ServiceFilterForm,
|
||||||
|
ServiceInstanceDeleteForm,
|
||||||
ServiceInstanceFilterForm,
|
ServiceInstanceFilterForm,
|
||||||
)
|
)
|
||||||
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
from servala.frontend.views.mixins import (
|
||||||
|
HtmxUpdateView,
|
||||||
|
HtmxViewMixin,
|
||||||
|
OrganizationViewMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ServiceListView(OrganizationViewMixin, ListView):
|
class ServiceListView(OrganizationViewMixin, ListView):
|
||||||
|
@ -28,10 +36,14 @@ class ServiceListView(OrganizationViewMixin, ListView):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return all services."""
|
"""Return all services."""
|
||||||
services = Service.objects.all().select_related("category")
|
services = (
|
||||||
|
Service.objects.all()
|
||||||
|
.select_related("category")
|
||||||
|
.prefetch_related("offerings__provider")
|
||||||
|
)
|
||||||
if self.filter_form.is_valid():
|
if self.filter_form.is_valid():
|
||||||
services = self.filter_form.filter_queryset(services)
|
services = self.filter_form.filter_queryset(services)
|
||||||
return services
|
return services.distinct()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def filter_form(self):
|
def filter_form(self):
|
||||||
|
@ -86,7 +98,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def selected_plane(self):
|
def selected_plane(self):
|
||||||
if self.select_form.data and self.select_form.is_valid():
|
if self.select_form.is_valid() and self.select_form.cleaned_data:
|
||||||
return self.select_form.cleaned_data["control_plane"]
|
return self.select_form.cleaned_data["control_plane"]
|
||||||
field = self.select_form.fields["control_plane"]
|
field = self.select_form.fields["control_plane"]
|
||||||
return field.initial or field.queryset.first()
|
return field.initial or field.queryset.first()
|
||||||
|
@ -104,6 +116,8 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
def get_instance_form(self):
|
def get_instance_form(self):
|
||||||
|
if not self.context_object or not self.context_object.model_form_class:
|
||||||
|
return None
|
||||||
return self.context_object.model_form_class(
|
return self.context_object.model_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
initial={
|
initial={
|
||||||
|
@ -129,10 +143,14 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
form = self.get_instance_form()
|
form = self.get_instance_form()
|
||||||
|
if not form: # Should not happen if context_object is valid, but as a safeguard
|
||||||
|
messages.error(self.request, _("Could not initialize service form."))
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
service_instance = ServiceInstance.create_instance(
|
service_instance = ServiceInstance.create_instance(
|
||||||
organization=self.organization,
|
organization=self.request.organization,
|
||||||
name=form.cleaned_data["name"],
|
name=form.cleaned_data["name"],
|
||||||
context=self.context_object,
|
context=self.context_object,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
|
@ -142,22 +160,24 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
messages.error(self.request, e.message or str(e))
|
messages.error(self.request, e.message or str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, str(e))
|
messages.error(
|
||||||
|
self.request, _("Error creating instance: {}").format(str(e))
|
||||||
|
)
|
||||||
|
|
||||||
# If the form is not valid or if the service creation failed, we render it again
|
# If the form is not valid or if the service creation failed, we render it again
|
||||||
context["service_form"] = form
|
context["service_form"] = form
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
|
class ServiceInstanceMixin:
|
||||||
"""View to display details of a specific service instance."""
|
|
||||||
|
|
||||||
template_name = "frontend/organizations/service_instance_detail.html"
|
|
||||||
context_object_name = "instance"
|
|
||||||
model = ServiceInstance
|
model = ServiceInstance
|
||||||
permission_type = "view"
|
context_object_name = "instance"
|
||||||
slug_field = "name"
|
slug_field = "name"
|
||||||
|
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
self._has_warned = False
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return service instance for the current organization."""
|
"""Return service instance for the current organization."""
|
||||||
return ServiceInstance.objects.filter(
|
return ServiceInstance.objects.filter(
|
||||||
|
@ -168,6 +188,187 @@ class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
|
||||||
"context__service_definition__service",
|
"context__service_definition__service",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_object(self, **kwargs):
|
||||||
|
instance = super().get_object(**kwargs)
|
||||||
|
if (
|
||||||
|
not instance.is_deleted
|
||||||
|
and not instance.kubernetes_object
|
||||||
|
and not self._has_warned
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
_(
|
||||||
|
"Could not retrieve instance details from Kubernetes. It might have been deleted externally."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._has_warned = True
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstanceDetailView(
|
||||||
|
ServiceInstanceMixin, OrganizationViewMixin, DetailView
|
||||||
|
):
|
||||||
|
template_name = "frontend/organizations/service_instance_detail.html"
|
||||||
|
permission_type = "view"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if (
|
||||||
|
not self.object.is_deleted
|
||||||
|
and self.object.kubernetes_object
|
||||||
|
and self.object.spec
|
||||||
|
):
|
||||||
|
context["spec_fieldsets"] = self.get_nested_spec()
|
||||||
|
context["has_change_permission"] = self.request.user.has_perm(
|
||||||
|
ServiceInstance.get_perm("change"), self.object
|
||||||
|
)
|
||||||
|
context["has_delete_permission"] = self.request.user.has_perm(
|
||||||
|
ServiceInstance.get_perm("delete"), self.object
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_nested_spec(self):
|
||||||
|
"""
|
||||||
|
Organize spec data into fieldsets similar to how the form does it.
|
||||||
|
"""
|
||||||
|
spec = self.object.spec or {}
|
||||||
|
if not spec:
|
||||||
|
return []
|
||||||
|
|
||||||
|
others = []
|
||||||
|
nested_fieldsets = {}
|
||||||
|
|
||||||
|
# First pass: organize fields into nested structures
|
||||||
|
for key, value in spec.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# This is a nested structure
|
||||||
|
if key not in nested_fieldsets:
|
||||||
|
nested_fieldsets[key] = {
|
||||||
|
"title": deslugify(key),
|
||||||
|
"fields": [],
|
||||||
|
"fieldsets": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process fields in the nested structure
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
if isinstance(sub_value, dict):
|
||||||
|
# Even deeper nesting
|
||||||
|
if sub_key not in nested_fieldsets[key]["fieldsets"]:
|
||||||
|
nested_fieldsets[key]["fieldsets"][sub_key] = {
|
||||||
|
"title": deslugify(sub_key),
|
||||||
|
"fields": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add fields from the deeper level
|
||||||
|
for leaf_key, leaf_value in sub_value.items():
|
||||||
|
nested_fieldsets[key]["fieldsets"][sub_key][
|
||||||
|
"fields"
|
||||||
|
].append(
|
||||||
|
{
|
||||||
|
"key": leaf_key,
|
||||||
|
"label": deslugify(leaf_key),
|
||||||
|
"value": leaf_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Add field to parent level
|
||||||
|
nested_fieldsets[key]["fields"].append(
|
||||||
|
{
|
||||||
|
"key": sub_key,
|
||||||
|
"label": deslugify(sub_key),
|
||||||
|
"value": sub_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# This is a top-level field
|
||||||
|
others.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": deslugify(key),
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second pass: Promote fields based on count
|
||||||
|
for group_key, group in list(nested_fieldsets.items()):
|
||||||
|
# Promote single sub-fieldsets to parent
|
||||||
|
for sub_key, sub_fieldset in list(group["fieldsets"].items()):
|
||||||
|
if len(sub_fieldset["fields"]) == 1:
|
||||||
|
field = sub_fieldset["fields"][0]
|
||||||
|
field["label"] = f"{sub_fieldset['title']}: {field['label']}"
|
||||||
|
group["fields"].append(field)
|
||||||
|
del group["fieldsets"][sub_key]
|
||||||
|
|
||||||
|
# Move single-field groups to others
|
||||||
|
total_fields = len(group["fields"])
|
||||||
|
for sub_fieldset in group["fieldsets"].values():
|
||||||
|
total_fields += len(sub_fieldset["fields"])
|
||||||
|
|
||||||
|
if (
|
||||||
|
total_fields == 1
|
||||||
|
and len(group["fields"]) == 1
|
||||||
|
and not group["fieldsets"]
|
||||||
|
):
|
||||||
|
field = group["fields"][0]
|
||||||
|
field["label"] = f"{group['title']}: {field['label']}"
|
||||||
|
others.append(field)
|
||||||
|
del nested_fieldsets[group_key]
|
||||||
|
elif total_fields == 0:
|
||||||
|
del nested_fieldsets[group_key]
|
||||||
|
|
||||||
|
fieldsets = []
|
||||||
|
if others:
|
||||||
|
fieldsets.append(
|
||||||
|
{
|
||||||
|
"title": "General",
|
||||||
|
"fields": others,
|
||||||
|
"fieldsets": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Create fieldsets from the organized data
|
||||||
|
for group in nested_fieldsets.values():
|
||||||
|
fieldsets.append(group)
|
||||||
|
|
||||||
|
return fieldsets
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstanceUpdateView(
|
||||||
|
ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView
|
||||||
|
):
|
||||||
|
template_name = "frontend/organizations/service_instance_update.html"
|
||||||
|
permission_type = "change"
|
||||||
|
|
||||||
|
def get_form_class(self):
|
||||||
|
return self.object.context.model_form_class
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs["instance"] = self.object.spec_object
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
try:
|
||||||
|
spec_data = form.get_nested_data().get("spec")
|
||||||
|
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_("Service instance '{name}' updated successfully.").format(
|
||||||
|
name=self.object.name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return redirect(self.object.urls.base)
|
||||||
|
except ValidationError as e:
|
||||||
|
messages.error(self.request, e.message or str(e))
|
||||||
|
return self.form_invalid(form)
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(
|
||||||
|
self.request, _("Error updating instance: {error}").format(error=str(e))
|
||||||
|
)
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return self.object.urls.base
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
||||||
template_name = "frontend/organizations/service_instances.html"
|
template_name = "frontend/organizations/service_instances.html"
|
||||||
|
@ -182,14 +383,61 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return all service instances for the current organization with filtering."""
|
"""Return all service instances for the current organization with filtering."""
|
||||||
queryset = ServiceInstance.objects.filter(
|
queryset = ServiceInstance.objects.filter(
|
||||||
organization=self.request.organization
|
organization=self.request.organization,
|
||||||
|
).select_related(
|
||||||
|
"context__service_offering__provider",
|
||||||
|
"context__control_plane",
|
||||||
|
"context__service_definition__service",
|
||||||
)
|
)
|
||||||
if self.filter_form.is_valid():
|
if self.filter_form.is_valid():
|
||||||
queryset = self.filter_form.filter_queryset(queryset)
|
queryset = self.filter_form.filter_queryset(queryset)
|
||||||
return queryset
|
status_filter = (
|
||||||
|
self.filter_form.cleaned_data.get("status")
|
||||||
|
if self.filter_form.is_valid()
|
||||||
|
else "active"
|
||||||
|
)
|
||||||
|
if status_filter == "active":
|
||||||
|
queryset = queryset.filter(is_deleted=False)
|
||||||
|
elif status_filter == "deleted":
|
||||||
|
queryset = queryset.filter(is_deleted=True)
|
||||||
|
return queryset.order_by("-created_at")
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["organization"] = self.request.organization
|
context["organization"] = self.request.organization
|
||||||
context["filter_form"] = self.filter_form
|
context["filter_form"] = self.filter_form
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstanceDeleteView(
|
||||||
|
ServiceInstanceMixin, OrganizationViewMixin, HtmxViewMixin, UpdateView
|
||||||
|
):
|
||||||
|
template_name = "frontend/organizations/service_instance_delete_form.html"
|
||||||
|
form_class = ServiceInstanceDeleteForm
|
||||||
|
permission_type = "delete"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
try:
|
||||||
|
self.object.delete_instance(user=self.request.user)
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_("Service instance '{name}' has been scheduled for deletion.").format(
|
||||||
|
name=self.object.name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = HttpResponse()
|
||||||
|
response["HX-Redirect"] = self.get_success_url()
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
_(
|
||||||
|
"An error occurred while trying to delete instance '{name}': {error}"
|
||||||
|
).format(name=self.object.name, error=str(e)),
|
||||||
|
)
|
||||||
|
response = HttpResponse()
|
||||||
|
response["HX-Redirect"] = str(self.object.urls.base)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return str(self.request.organization.urls.instances)
|
||||||
|
|
|
@ -10,9 +10,12 @@ Servala is run using environment variables. Documentation:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sentry_sdk
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from servala.__about__ import __version__ as version
|
||||||
|
|
||||||
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
||||||
DEBUG = SERVALA_ENVIRONMENT == "development"
|
DEBUG = SERVALA_ENVIRONMENT == "development"
|
||||||
|
@ -87,6 +90,42 @@ SOCIALACCOUNT_PROVIDERS = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SERVALA_STORAGE_BUCKET_NAME = os.environ.get("SERVALA_STORAGE_BUCKET_NAME")
|
||||||
|
SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL")
|
||||||
|
SERVALA_ACCESS_KEY_ID = os.environ.get("SERVALA_ACCESS_KEY_ID")
|
||||||
|
SERVALA_SECRET_ACCESS_KEY = os.environ.get("SERVALA_SECRET_ACCESS_KEY")
|
||||||
|
SERVALA_S3_REGION_NAME = os.environ.get("SERVALA_S3_REGION_NAME", "eu-central-1")
|
||||||
|
SERVALA_S3_ADDRESSING_STYLE = os.environ.get("SERVALA_S3_ADDRESSING_STYLE", "virtual")
|
||||||
|
SERVALA_S3_SIGNATURE_VERSION = os.environ.get("SERVALA_S3_SIGNATURE_VERSION", "s3v4")
|
||||||
|
|
||||||
|
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
|
||||||
|
if all(
|
||||||
|
[
|
||||||
|
SERVALA_STORAGE_BUCKET_NAME,
|
||||||
|
SERVALA_S3_ENDPOINT_URL,
|
||||||
|
SERVALA_ACCESS_KEY_ID,
|
||||||
|
SERVALA_SECRET_ACCESS_KEY,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "storages.backends.s3.S3Storage",
|
||||||
|
"OPTIONS": {
|
||||||
|
"bucket_name": SERVALA_STORAGE_BUCKET_NAME,
|
||||||
|
"endpoint_url": SERVALA_S3_ENDPOINT_URL,
|
||||||
|
"access_key": SERVALA_ACCESS_KEY_ID,
|
||||||
|
"secret_key": SERVALA_SECRET_ACCESS_KEY,
|
||||||
|
"region_name": SERVALA_S3_REGION_NAME,
|
||||||
|
"addressing_style": SERVALA_S3_ADDRESSING_STYLE,
|
||||||
|
"signature_version": SERVALA_S3_SIGNATURE_VERSION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#######################################
|
#######################################
|
||||||
# Non-configurable settings below #
|
# Non-configurable settings below #
|
||||||
#######################################
|
#######################################
|
||||||
|
@ -218,3 +257,13 @@ TIME_ZONE = "UTC"
|
||||||
|
|
||||||
if SERVALA_ENVIRONMENT in ("staging", "production"):
|
if SERVALA_ENVIRONMENT in ("staging", "production"):
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
|
SERVALA_SENTRY_DSN = os.environ.get("SERVALA_SENTRY_DSN")
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=SERVALA_SENTRY_DSN,
|
||||||
|
integrations=[DjangoIntegration()],
|
||||||
|
auto_session_tracking=False,
|
||||||
|
traces_sample_rate=0.01,
|
||||||
|
release=version,
|
||||||
|
environment=SERVALA_ENVIRONMENT,
|
||||||
|
)
|
||||||
|
|
|
@ -85,3 +85,11 @@ html[data-bs-theme="dark"] .btn-outline-primary, .btn-outline-primary {
|
||||||
a.btn-keycloak {
|
a.btn-keycloak {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
.page-heading h3 {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.hide-form-errors .alert.form-errors {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue