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_SECRET=''
|
||||
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:
|
||||
tags:
|
||||
- "*"
|
||||
paths:
|
||||
- "deployment/**"
|
||||
- "docker/**"
|
||||
- "src/**"
|
||||
- "Dockerfile"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
@ -44,7 +51,7 @@ jobs:
|
|||
esac
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
@ -80,7 +87,7 @@ jobs:
|
|||
esac
|
||||
|
||||
- name: Deploy to OpenShift
|
||||
uses: docker://quay.io/appuio/oc:v4.16
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
@ -97,7 +104,7 @@ jobs:
|
|||
OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }}
|
||||
|
||||
- name: Verify deployment
|
||||
uses: docker://quay.io/appuio/oc:v4.16
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
|
|
@ -3,6 +3,13 @@ name: Build and Deploy Staging
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "deployment/**"
|
||||
- "docker/**"
|
||||
- "src/**"
|
||||
- "Dockerfile"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
@ -28,7 +35,7 @@ jobs:
|
|||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
@ -49,7 +56,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to OpenShift
|
||||
uses: docker://quay.io/appuio/oc:v4.16
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docs/Dockerfile
|
||||
|
@ -52,7 +52,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to OpenShift
|
||||
uses: docker://quay.io/appuio/oc:v4.16
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
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:
|
||||
push:
|
||||
paths:
|
||||
- "src/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
@ -17,7 +21,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v5
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
|
||||
- name: Run tests
|
||||
run: uv run --env-file=.env.example pytest
|
||||
|
|
|
@ -1 +1 @@
|
|||
3.12
|
||||
3.13
|
||||
|
|
|
@ -2,3 +2,4 @@ resources:
|
|||
- deployment.yaml
|
||||
- service.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
|
||||
patches:
|
||||
- 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
|
||||
patches:
|
||||
- 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: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:organizations.adoc[]
|
||||
** xref:authentication.adoc[]
|
||||
|
@ -9,7 +13,6 @@
|
|||
** xref:service-catalog.adoc[]
|
||||
** xref:service-instances.adoc[]
|
||||
** xref:api.adoc[]
|
||||
** xref:database-diagram.adoc[]
|
||||
|
||||
* Cloud Providers
|
||||
** 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/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://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^]
|
||||
|
|
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
|
||||
|
||||
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.
|
||||
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^].
|
||||
|
|
|
@ -3,22 +3,24 @@ name = "servala"
|
|||
version = "0.0.0"
|
||||
description = "Servala portal server and frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
requires-python = ">=3.13.4"
|
||||
dependencies = [
|
||||
"argon2-cffi>=23.1.0",
|
||||
"cryptography>=44.0.2",
|
||||
"django==5.2b1",
|
||||
"django-allauth>=65.5.0",
|
||||
"argon2-cffi>=25.1.0",
|
||||
"cryptography>=45.0.3",
|
||||
"django==5.2.2",
|
||||
"django-allauth>=65.9.0",
|
||||
"django-fernet-encrypted-fields>=0.3.0",
|
||||
"django-scopes>=2.0.0",
|
||||
"django-storages>=1.14.6",
|
||||
"django-template-partials>=24.4",
|
||||
"jsonschema>=4.23.0",
|
||||
"jsonschema>=4.24.0",
|
||||
"kubernetes>=32.0.1",
|
||||
"pillow>=11.1.0",
|
||||
"pillow>=11.2.1",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"pyjwt>=2.10.1",
|
||||
"requests>=2.32.3",
|
||||
"rules>=3.5",
|
||||
"sentry-sdk[django]>=2.29.1",
|
||||
"urlman>=2.0.2",
|
||||
]
|
||||
|
||||
|
@ -26,15 +28,15 @@ dependencies = [
|
|||
dev = [
|
||||
"black>=25.1.0",
|
||||
"bumpver>=2024.1130",
|
||||
"coverage>=7.7.0",
|
||||
"coverage>=7.8.2",
|
||||
"djlint>=1.36.4",
|
||||
"flake8>=7.1.2",
|
||||
"flake8>=7.2.0",
|
||||
"flake8-bugbear>=24.12.12",
|
||||
"flake8-pyproject>=1.2.3",
|
||||
"isort>=6.0.1",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-django>=4.10.0",
|
||||
"pytest>=8.4.0",
|
||||
"pytest-cov>=6.1.1",
|
||||
"pytest-django>=4.11.1",
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
|
@ -44,7 +46,7 @@ known_first_party = "servala"
|
|||
[tool.flake8]
|
||||
max-line-length = 160
|
||||
exclude = ".venv"
|
||||
ignore = "E203"
|
||||
ignore = "E203,W503"
|
||||
|
||||
[tool.djlint]
|
||||
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")
|
||||
autocomplete_fields = ("cloud_provider",)
|
||||
actions = ["test_kubernetes_connection"]
|
||||
ordering = ("name",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
|
|
|
@ -9,6 +9,18 @@ from django.utils.translation import gettext_lazy as _
|
|||
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):
|
||||
# Get the field from the model
|
||||
field = model._meta.get_field(field_name)
|
||||
|
@ -47,7 +59,7 @@ def generate_django_model(schema, group, version, kind):
|
|||
|
||||
# create the model class
|
||||
model_name = kind
|
||||
model_class = type(model_name, (models.Model,), model_fields)
|
||||
model_class = type(model_name, (CRDModel,), model_fields)
|
||||
return model_class
|
||||
|
||||
|
||||
|
@ -138,6 +150,21 @@ def get_django_field(
|
|||
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:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -146,6 +173,11 @@ class CrdModelFormMixin:
|
|||
for field in ("organization", "context"):
|
||||
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):
|
||||
field = self.fields[field_name]
|
||||
if field and field.label.startswith(label):
|
||||
|
@ -156,12 +188,20 @@ class CrdModelFormMixin:
|
|||
|
||||
# General fieldset for non-spec 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:
|
||||
fieldsets.append(
|
||||
{"title": "General", "fields": general_fields, "fieldsets": []}
|
||||
)
|
||||
fieldset = {"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
|
||||
others = []
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import copy
|
||||
import json
|
||||
|
||||
import kubernetes
|
||||
|
@ -6,7 +7,8 @@ import urlman
|
|||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
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.translation import gettext_lazy as _
|
||||
from encrypted_fields.fields import EncryptedJSONField
|
||||
|
@ -188,6 +190,10 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
|||
def get_kubernetes_client(self):
|
||||
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):
|
||||
if not self.api_credentials:
|
||||
return False, _("No API credentials provided")
|
||||
|
@ -355,11 +361,32 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
def __str__(self):
|
||||
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
|
||||
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()
|
||||
|
||||
extensions_api = kubernetes.client.ApiextensionsV1Api(client)
|
||||
|
@ -368,10 +395,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
for crd in crds.items:
|
||||
if matching_crd:
|
||||
break
|
||||
if crd.spec.group == group:
|
||||
if crd.spec.group == self.group:
|
||||
for served_version in crd.spec.versions:
|
||||
if served_version.name == version and served_version.served:
|
||||
if crd.spec.names.kind == kind:
|
||||
if served_version.name == self.version and served_version.served:
|
||||
if crd.spec.names.kind == self.kind:
|
||||
matching_crd = crd
|
||||
break
|
||||
return matching_crd
|
||||
|
@ -382,9 +409,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
if result := cache.get(cache_key):
|
||||
return result
|
||||
|
||||
version = self.service_definition.api_definition["version"]
|
||||
if not self.resource_definition:
|
||||
return
|
||||
|
||||
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()
|
||||
timeout_seconds = 60 * 60 * 24
|
||||
cache.set(cache_key, result, timeout=timeout_seconds)
|
||||
|
@ -394,10 +425,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
def django_model(self):
|
||||
from servala.core.crd import generate_django_model
|
||||
|
||||
if not self.resource_schema:
|
||||
return
|
||||
|
||||
kwargs = {
|
||||
key: value
|
||||
for key, value in self.service_definition.api_definition.items()
|
||||
if key in ("group", "version", "kind")
|
||||
"group": self.group,
|
||||
"version": self.version,
|
||||
"kind": self.kind,
|
||||
}
|
||||
return generate_django_model(self.resource_schema, **kwargs)
|
||||
|
||||
|
@ -405,6 +439,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
def model_form_class(self):
|
||||
from servala.core.crd import generate_model_form_class
|
||||
|
||||
if not self.django_model:
|
||||
return
|
||||
return generate_model_form_class(self.django_model)
|
||||
|
||||
|
||||
|
@ -497,6 +533,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
|
||||
class urls(urlman.Urls):
|
||||
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
|
||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||
|
@ -517,12 +567,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
)
|
||||
|
||||
try:
|
||||
group = context.service_definition.api_definition["group"]
|
||||
version = context.service_definition.api_definition["version"]
|
||||
kind = context.service_definition.api_definition["kind"]
|
||||
create_data = {
|
||||
"apiVersion": f"{group}/{version}",
|
||||
"kind": kind,
|
||||
"apiVersion": f"{context.group}/{context.version}",
|
||||
"kind": context.kind,
|
||||
"metadata": {
|
||||
"name": name,
|
||||
"namespace": organization.namespace,
|
||||
|
@ -531,18 +578,12 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
}
|
||||
if label := context.control_plane.required_label:
|
||||
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
||||
api_instance = client.CustomObjectsApi(
|
||||
context.control_plane.get_kubernetes_client()
|
||||
)
|
||||
plural = kind.lower()
|
||||
if not plural.endswith("s"):
|
||||
plural = f"{plural}s"
|
||||
|
||||
api_instance = context.control_plane.custom_objects_api
|
||||
api_instance.create_namespaced_custom_object(
|
||||
group=group,
|
||||
version=version,
|
||||
group=context.group,
|
||||
version=context.version,
|
||||
namespace=organization.namespace,
|
||||
plural=plural,
|
||||
plural=context.kind_plural,
|
||||
body=create_data,
|
||||
)
|
||||
except Exception as e:
|
||||
|
@ -556,3 +597,194 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
|
||||
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
||||
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.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import (
|
||||
|
@ -6,6 +7,7 @@ from servala.core.models import (
|
|||
ControlPlane,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
)
|
||||
|
||||
|
@ -17,13 +19,17 @@ class ServiceFilterForm(forms.Form):
|
|||
cloud_provider = forms.ModelChoiceField(
|
||||
queryset=CloudProvider.objects.all(), required=False
|
||||
)
|
||||
q = forms.CharField(required=False)
|
||||
q = forms.CharField(label=_("Search"), required=False)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
if category := self.cleaned_data.get("category"):
|
||||
queryset = queryset.filter(category=category)
|
||||
if cloud_provider := self.cleaned_data.get("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
|
||||
|
||||
|
||||
|
@ -37,6 +43,8 @@ class ControlPlaneSelectForm(forms.Form):
|
|||
def __init__(self, *args, planes=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["control_plane"].queryset = planes
|
||||
if planes and planes.count() == 1:
|
||||
self.fields["control_plane"].initial = planes.first()
|
||||
|
||||
|
||||
class ServiceInstanceFilterForm(forms.Form):
|
||||
|
@ -68,21 +76,50 @@ class ServiceInstanceFilterForm(forms.Form):
|
|||
def filter_queryset(self, queryset):
|
||||
if self.is_valid():
|
||||
data = self.cleaned_data
|
||||
if data["name"]:
|
||||
if data.get("name"):
|
||||
queryset = queryset.filter(name__icontains=data["name"])
|
||||
if data["service"]:
|
||||
if data.get("service"):
|
||||
queryset = queryset.filter(
|
||||
context__service_definition__service=data["service"]
|
||||
)
|
||||
if data["provider"]:
|
||||
if data.get("provider"):
|
||||
queryset = queryset.filter(
|
||||
context__service_offering__provider=data["provider"]
|
||||
)
|
||||
if data["control_plane"]:
|
||||
if data.get("control_plane"):
|
||||
queryset = queryset.filter(context__control_plane=data["control_plane"])
|
||||
if data["status"]:
|
||||
if data["status"] == "active":
|
||||
status = data.get("status")
|
||||
if status == "active":
|
||||
queryset = queryset.filter(is_deleted=False)
|
||||
else:
|
||||
elif status == "deleted":
|
||||
queryset = queryset.filter(is_deleted=True)
|
||||
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="page-heading">
|
||||
<h3>
|
||||
<span>
|
||||
{% block page_title %}
|
||||
Dashboard
|
||||
{% endblock page_title %}
|
||||
</span>
|
||||
{% block page_title_extra %}
|
||||
{% endblock page_title_extra %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% load i18n %}
|
||||
{% 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>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
|
|
|
@ -28,8 +28,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for offering in service.offerings.all %}
|
||||
<div class="card col-6 col-lg-3 col-md-4">
|
||||
<div class="col-6 col-lg-3 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
{% if offering.provider.logo %}
|
||||
<img src="{{ offering.provider.logo.url }}"
|
||||
|
@ -54,6 +56,7 @@
|
|||
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
@ -61,5 +64,6 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% 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" %}
|
||||
{% load i18n static %}
|
||||
{% load i18n static pprint_filters %}
|
||||
{% block html_title %}
|
||||
{% block page_title %}
|
||||
{{ instance.name }}
|
||||
{% endblock page_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 %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>{% translate "Details" %}</h5>
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Details" %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">{% translate "Service" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
|
@ -27,7 +44,7 @@
|
|||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Created By" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.created_by }}
|
||||
{{ instance.created_by|default:"-" }}
|
||||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Created At" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
|
@ -40,7 +57,14 @@
|
|||
<dt class="col-sm-4">{% translate "Status" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% 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 %}
|
||||
<span class="badge text-bg-success">{% translate "Active" %}</span>
|
||||
{% endif %}
|
||||
|
@ -49,6 +73,181 @@
|
|||
</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>
|
||||
</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 %}
|
||||
|
|
|
@ -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,8 +16,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for service in services %}
|
||||
<div class="card col-6 col-lg-3 col-md-4">
|
||||
<div class="col-6 col-lg-3 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
{% if service.logo %}
|
||||
<img src="{{ service.logo.url }}"
|
||||
|
@ -39,6 +41,7 @@
|
|||
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
@ -46,6 +49,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
||||
{% endblock content %}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
{% csrf_token %}
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
{% if not fieldset.hidden %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if forloop.first %}active{% endif %}"
|
||||
id="{{ fieldset.title|slugify }}-tab"
|
||||
|
@ -18,11 +19,12 @@
|
|||
{{ fieldset.title }}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="tab-content" id="myTabContent">
|
||||
{% 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 }}"
|
||||
role="tabpanel"
|
||||
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(),
|
||||
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 (
|
||||
ServiceDetailView,
|
||||
ServiceInstanceDeleteView,
|
||||
ServiceInstanceDetailView,
|
||||
ServiceInstanceListView,
|
||||
ServiceInstanceUpdateView,
|
||||
ServiceListView,
|
||||
ServiceOfferingDetailView,
|
||||
)
|
||||
|
@ -20,8 +22,10 @@ __all__ = [
|
|||
"OrganizationDashboardView",
|
||||
"OrganizationUpdateView",
|
||||
"ServiceDetailView",
|
||||
"ServiceInstanceDeleteView",
|
||||
"ServiceInstanceDetailView",
|
||||
"ServiceInstanceListView",
|
||||
"ServiceInstanceUpdateView",
|
||||
"ServiceListView",
|
||||
"ServiceOfferingDetailView",
|
||||
"ProfileView",
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
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 (
|
||||
ControlPlaneCRD,
|
||||
Service,
|
||||
|
@ -13,9 +16,14 @@ from servala.core.models import (
|
|||
from servala.frontend.forms.service import (
|
||||
ControlPlaneSelectForm,
|
||||
ServiceFilterForm,
|
||||
ServiceInstanceDeleteForm,
|
||||
ServiceInstanceFilterForm,
|
||||
)
|
||||
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
||||
from servala.frontend.views.mixins import (
|
||||
HtmxUpdateView,
|
||||
HtmxViewMixin,
|
||||
OrganizationViewMixin,
|
||||
)
|
||||
|
||||
|
||||
class ServiceListView(OrganizationViewMixin, ListView):
|
||||
|
@ -28,10 +36,14 @@ class ServiceListView(OrganizationViewMixin, ListView):
|
|||
|
||||
def get_queryset(self):
|
||||
"""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():
|
||||
services = self.filter_form.filter_queryset(services)
|
||||
return services
|
||||
return services.distinct()
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
|
@ -86,7 +98,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
|
||||
@cached_property
|
||||
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"]
|
||||
field = self.select_form.fields["control_plane"]
|
||||
return field.initial or field.queryset.first()
|
||||
|
@ -104,6 +116,8 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
).first()
|
||||
|
||||
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(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
initial={
|
||||
|
@ -129,10 +143,14 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
return self.render_to_response(context)
|
||||
|
||||
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():
|
||||
try:
|
||||
service_instance = ServiceInstance.create_instance(
|
||||
organization=self.organization,
|
||||
organization=self.request.organization,
|
||||
name=form.cleaned_data["name"],
|
||||
context=self.context_object,
|
||||
created_by=request.user,
|
||||
|
@ -142,22 +160,24 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
except ValidationError as e:
|
||||
messages.error(self.request, e.message or str(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
|
||||
context["service_form"] = form
|
||||
return self.render_to_response(context)
|
||||
|
||||
|
||||
class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
|
||||
"""View to display details of a specific service instance."""
|
||||
|
||||
template_name = "frontend/organizations/service_instance_detail.html"
|
||||
context_object_name = "instance"
|
||||
class ServiceInstanceMixin:
|
||||
model = ServiceInstance
|
||||
permission_type = "view"
|
||||
context_object_name = "instance"
|
||||
slug_field = "name"
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
self._has_warned = False
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return service instance for the current organization."""
|
||||
return ServiceInstance.objects.filter(
|
||||
|
@ -168,6 +188,187 @@ class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
|
|||
"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):
|
||||
template_name = "frontend/organizations/service_instances.html"
|
||||
|
@ -182,14 +383,61 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
|||
def get_queryset(self):
|
||||
"""Return all service instances for the current organization with filtering."""
|
||||
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():
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["organization"] = self.request.organization
|
||||
context["filter_form"] = self.filter_form
|
||||
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 sentry_sdk
|
||||
from pathlib import Path
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from django.contrib import messages
|
||||
from servala.__about__ import __version__ as version
|
||||
|
||||
SERVALA_ENVIRONMENT = os.environ.get("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 #
|
||||
#######################################
|
||||
|
@ -218,3 +257,13 @@ TIME_ZONE = "UTC"
|
|||
|
||||
if SERVALA_ENVIRONMENT in ("staging", "production"):
|
||||
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 {
|
||||
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