Compare commits

..

No commits in common. "main" and "2025.04.14-1" have entirely different histories.

43 changed files with 673 additions and 2473 deletions

View file

@ -47,17 +47,3 @@ SERVALA_DEFAULT_ORIGIN='1'
SERVALA_KEYCLOAK_CLIENT_ID='portal.servala.com' SERVALA_KEYCLOAK_CLIENT_ID='portal.servala.com'
SERVALA_KEYCLOAK_CLIENT_SECRET='' SERVALA_KEYCLOAK_CLIENT_SECRET=''
SERVALA_KEYCLOAK_SERVER_URL='' SERVALA_KEYCLOAK_SERVER_URL=''
# S3 Storage settings (optional, for using S3 compatible storage for media files)
# If these are set, Django will use S3 for default file storage.
# Defaults are indicated if any.
# SERVALA_STORAGE_BUCKET_NAME=''
# SERVALA_S3_ENDPOINT_URL=''
# SERVALA_ACCESS_KEY_ID=''
# SERVALA_SECRET_ACCESS_KEY=''
# SERVALA_S3_REGION_NAME='eu-central-1'
# SERVALA_S3_ADDRESSING_STYLE='virtual'
# SERVALA_S3_SIGNATURE_VERSION='s3v4'
# Configuration for Sentry error reporting
SERVALA_SENTRY_DSN=''

View file

@ -4,13 +4,6 @@ on:
push: push:
tags: tags:
- "*" - "*"
paths:
- "deployment/**"
- "docker/**"
- "src/**"
- "Dockerfile"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@ -51,7 +44,7 @@ jobs:
esac esac
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
@ -87,7 +80,7 @@ jobs:
esac esac
- name: Deploy to OpenShift - name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.18 uses: docker://quay.io/appuio/oc:v4.16
with: with:
entrypoint: /bin/bash entrypoint: /bin/bash
args: | args: |
@ -104,7 +97,7 @@ jobs:
OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }} OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }}
- name: Verify deployment - name: Verify deployment
uses: docker://quay.io/appuio/oc:v4.18 uses: docker://quay.io/appuio/oc:v4.16
with: with:
entrypoint: /bin/bash entrypoint: /bin/bash
args: | args: |

View file

@ -3,13 +3,6 @@ name: Build and Deploy Staging
on: on:
push: push:
branches: [main] branches: [main]
paths:
- "deployment/**"
- "docker/**"
- "src/**"
- "Dockerfile"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@ -35,7 +28,7 @@ jobs:
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
@ -56,7 +49,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy to OpenShift - name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.18 uses: docker://quay.io/appuio/oc:v4.16
with: with:
entrypoint: /bin/bash entrypoint: /bin/bash
args: | args: |

View file

@ -30,7 +30,7 @@ jobs:
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docs/Dockerfile file: docs/Dockerfile
@ -52,7 +52,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy to OpenShift - name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.18 uses: docker://quay.io/appuio/oc:v4.16
with: with:
entrypoint: /bin/bash entrypoint: /bin/bash
args: | args: |

View file

@ -1,33 +0,0 @@
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

View file

@ -2,10 +2,6 @@ name: Tests
on: on:
push: push:
paths:
- "src/**"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@ -21,7 +17,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install uv - name: Install uv
uses: https://github.com/astral-sh/setup-uv@v6 uses: https://github.com/astral-sh/setup-uv@v5
- name: Run tests - name: Run tests
run: uv run --env-file=.env.example pytest run: uv run --env-file=.env.example pytest

View file

@ -1 +1 @@
3.13 3.12

View file

@ -2,4 +2,3 @@ resources:
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- cronjob.yaml - cronjob.yaml
- objectstorage.yaml

View file

@ -1,9 +0,0 @@
apiVersion: appcat.vshn.io/v1
kind: ObjectBucket
metadata:
name: portal-storage
spec:
parameters:
region: lpg
writeConnectionSecretToRef:
name: portal-storage-creds

View file

@ -11,4 +11,3 @@ resources:
- ingress.yaml - ingress.yaml
patches: patches:
- path: portal-deployment.yaml - path: portal-deployment.yaml
- path: objectstorage.yaml

View file

@ -1,7 +0,0 @@
apiVersion: appcat.vshn.io/v1
kind: ObjectBucket
metadata:
name: portal-storage
spec:
parameters:
bucketName: servala-portal-storage-production

View file

@ -11,4 +11,3 @@ resources:
- ingress.yaml - ingress.yaml
patches: patches:
- path: portal-deployment.yaml - path: portal-deployment.yaml
- path: objectstorage.yaml

View file

@ -1,7 +0,0 @@
apiVersion: appcat.vshn.io/v1
kind: ObjectBucket
metadata:
name: portal-storage
spec:
parameters:
bucketName: servala-portal-storage-staging

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View file

@ -1,469 +0,0 @@
<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="&lt;mxfile&gt;&lt;diagram id=&quot;iQWVNTns26_xaYTS_M6j&quot; name=&quot;Page-1&quot;&gt;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&lt;/diagram&gt;&lt;/mxfile&gt;">
<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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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>

Before

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1,226 +0,0 @@
<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="&lt;mxfile&gt;&lt;diagram id=&quot;oCg05mIILSXJTHsOPe0H&quot; name=&quot;Page-1&quot;&gt;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==&lt;/diagram&gt;&lt;/mxfile&gt;">
<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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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: &quot;Helvetica&quot;; 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="&quot;Helvetica&quot;" 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>

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -2,10 +2,6 @@
* xref:terminology.adoc[] * xref:terminology.adoc[]
* xref:web-portal.adoc[] * xref:web-portal.adoc[]
** xref:web-portal-admin.adoc[Admin]
** xref:web-portal-controlplanes.adoc[Control-Planes]
* xref:web-portal-planning.adoc[]
** xref:user-stories.adoc[] ** xref:user-stories.adoc[]
** xref:organizations.adoc[] ** xref:organizations.adoc[]
** xref:authentication.adoc[] ** xref:authentication.adoc[]
@ -13,6 +9,7 @@
** xref:service-catalog.adoc[] ** xref:service-catalog.adoc[]
** xref:service-instances.adoc[] ** xref:service-instances.adoc[]
** xref:api.adoc[] ** xref:api.adoc[]
** xref:database-diagram.adoc[]
* Cloud Providers * Cloud Providers
** xref:exoscale-osb.adoc[] ** xref:exoscale-osb.adoc[]

View file

@ -408,7 +408,7 @@ POST /orgs/:uuid/usage <1>
* https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html[APPUiO Control API Architecture^] * https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html[APPUiO Control API Architecture^]
* https://kb.vshn.ch/appuio-cloud/references/architecture/invitations.html[APPUiO Invitations] * https://kb.vshn.ch/appuio-cloud/references/architecture/invitations.html[APPUiO Invitations]
* https://github.com/vshn/crossplane-service-broker[Crossplane Service Broker (Code)^] - https://kb.vshn.ch/app-catalog/csp/spks/crossplane/overview.html[Crossplane Service Broker (Docs)^] * 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/swisscom-service-broker[Swisscom Service Broker^] * https://github.com/vshn/swisscom-service-broker[Swisscom Service Broker^]
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^] * https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^]
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^] * https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^]

View file

@ -1,59 +0,0 @@
= 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.

View file

@ -1,30 +0,0 @@
= 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
----

View file

@ -1,64 +0,0 @@
= 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^].

View file

@ -1,17 +1,64 @@
= Web Portal = Web Portal
image::web-portal-arch-current.drawio.svg[] image::web-portal-arch.drawio.svg[]
[TIP]
====
* Production: https://portal.servala.com/[portal.servala.com^]
* Staging: https://staging.portal.servala.com/[staging.portal.servala.com^]
====
The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala. The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala.
Servala is the brand for the product formerly known as "VSHN Application Marketplace".
It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way. It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way.
The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view. The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view.
External resources to read about it:
* 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^]. 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^].

View file

@ -3,24 +3,22 @@ name = "servala"
version = "0.0.0" version = "0.0.0"
description = "Servala portal server and frontend" description = "Servala portal server and frontend"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13.4" requires-python = ">=3.12"
dependencies = [ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=23.1.0",
"cryptography>=45.0.3", "cryptography>=44.0.2",
"django==5.2.2", "django==5.2b1",
"django-allauth>=65.9.0", "django-allauth>=65.5.0",
"django-fernet-encrypted-fields>=0.3.0", "django-fernet-encrypted-fields>=0.3.0",
"django-scopes>=2.0.0", "django-scopes>=2.0.0",
"django-storages>=1.14.6",
"django-template-partials>=24.4", "django-template-partials>=24.4",
"jsonschema>=4.24.0", "jsonschema>=4.23.0",
"kubernetes>=32.0.1", "kubernetes>=32.0.1",
"pillow>=11.2.1", "pillow>=11.1.0",
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"requests>=2.32.3", "requests>=2.32.3",
"rules>=3.5", "rules>=3.5",
"sentry-sdk[django]>=2.29.1",
"urlman>=2.0.2", "urlman>=2.0.2",
] ]
@ -28,15 +26,15 @@ dependencies = [
dev = [ dev = [
"black>=25.1.0", "black>=25.1.0",
"bumpver>=2024.1130", "bumpver>=2024.1130",
"coverage>=7.8.2", "coverage>=7.7.0",
"djlint>=1.36.4", "djlint>=1.36.4",
"flake8>=7.2.0", "flake8>=7.1.2",
"flake8-bugbear>=24.12.12", "flake8-bugbear>=24.12.12",
"flake8-pyproject>=1.2.3", "flake8-pyproject>=1.2.3",
"isort>=6.0.1", "isort>=6.0.1",
"pytest>=8.4.0", "pytest>=8.3.5",
"pytest-cov>=6.1.1", "pytest-cov>=6.0.0",
"pytest-django>=4.11.1", "pytest-django>=4.10.0",
] ]
[tool.isort] [tool.isort]
@ -46,7 +44,7 @@ known_first_party = "servala"
[tool.flake8] [tool.flake8]
max-line-length = 160 max-line-length = 160
exclude = ".venv" exclude = ".venv"
ignore = "E203,W503" ignore = "E203"
[tool.djlint] [tool.djlint]
extend_exclude = "src/servala/static/mazer" extend_exclude = "src/servala/static/mazer"

View file

@ -1,43 +0,0 @@
{
"$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
}

View file

@ -126,7 +126,6 @@ class ControlPlaneAdmin(admin.ModelAdmin):
search_fields = ("name", "description") search_fields = ("name", "description")
autocomplete_fields = ("cloud_provider",) autocomplete_fields = ("cloud_provider",)
actions = ["test_kubernetes_connection"] actions = ["test_kubernetes_connection"]
ordering = ("name",)
fieldsets = ( fieldsets = (
( (

View file

@ -9,18 +9,6 @@ from django.utils.translation import gettext_lazy as _
from servala.core.models import ServiceInstance from servala.core.models import ServiceInstance
class CRDModel(models.Model):
"""Base class for all virtual CRD models"""
def __init__(self, **kwargs):
if spec := kwargs.pop("spec", None):
kwargs.update(unnest_data({"spec": spec}))
super().__init__(**kwargs)
class Meta:
abstract = True
def duplicate_field(field_name, model): def duplicate_field(field_name, model):
# Get the field from the model # Get the field from the model
field = model._meta.get_field(field_name) field = model._meta.get_field(field_name)
@ -59,7 +47,7 @@ def generate_django_model(schema, group, version, kind):
# create the model class # create the model class
model_name = kind model_name = kind
model_class = type(model_name, (CRDModel,), model_fields) model_class = type(model_name, (models.Model,), model_fields)
return model_class return model_class
@ -150,21 +138,6 @@ def get_django_field(
return models.CharField(max_length=255, **kwargs) return models.CharField(max_length=255, **kwargs)
def unnest_data(data):
result = {}
def _flatten_dict(d, parent_key=""):
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key else key
if isinstance(value, dict):
_flatten_dict(value, new_key)
else:
result[new_key] = value
_flatten_dict(data)
return result
class CrdModelFormMixin: class CrdModelFormMixin:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -173,11 +146,6 @@ class CrdModelFormMixin:
for field in ("organization", "context"): for field in ("organization", "context"):
self.fields[field].widget = forms.HiddenInput() self.fields[field].widget = forms.HiddenInput()
if self.instance and self.instance.pk:
self.fields["name"].disabled = True
self.fields["name"].help_text = _("Name cannot be changed after creation.")
self.fields["name"].widget = forms.HiddenInput()
def strip_title(self, field_name, label): def strip_title(self, field_name, label):
field = self.fields[field_name] field = self.fields[field_name]
if field and field.label.startswith(label): if field and field.label.startswith(label):
@ -188,20 +156,12 @@ class CrdModelFormMixin:
# General fieldset for non-spec fields # General fieldset for non-spec fields
general_fields = [ general_fields = [
field_name field for field in self.fields if not field.startswith("spec.")
for field_name, field in self.fields.items()
if not field_name.startswith("spec.")
] ]
if general_fields: if general_fields:
fieldset = {"title": "General", "fields": general_fields, "fieldsets": []} fieldsets.append(
if all( {"title": "General", "fields": general_fields, "fieldsets": []}
[ )
isinstance(self.fields[field].widget, forms.HiddenInput)
for field in general_fields
]
):
fieldset["hidden"] = True
fieldsets.append(fieldset)
# Process spec fields # Process spec fields
others = [] others = []

View file

@ -1,4 +1,3 @@
import copy
import json import json
import kubernetes import kubernetes
@ -7,8 +6,7 @@ import urlman
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction from django.db import IntegrityError, models
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField from encrypted_fields.fields import EncryptedJSONField
@ -190,10 +188,6 @@ class ControlPlane(ServalaModelMixin, models.Model):
def get_kubernetes_client(self): def get_kubernetes_client(self):
return kubernetes.client.ApiClient(self.kubernetes_config) return kubernetes.client.ApiClient(self.kubernetes_config)
@cached_property
def custom_objects_api(self):
return client.CustomObjectsApi(self.get_kubernetes_client())
def test_connection(self): def test_connection(self):
if not self.api_credentials: if not self.api_credentials:
return False, _("No API credentials provided") return False, _("No API credentials provided")
@ -361,32 +355,11 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def __str__(self): def __str__(self):
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}" return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
@cached_property
def group(self):
return self.service_definition.api_definition["group"]
@cached_property
def version(self):
return self.service_definition.api_definition["version"]
@cached_property
def kind(self):
return self.service_definition.api_definition["kind"]
@cached_property
def kind_plural(self):
if (
hasattr(self.resource_definition, "status")
and hasattr(self.resource_definition.status, "accepted_names")
and self.resource_definition.status.accepted_names
):
return self.resource_definition.status.accepted_names.plural
if self.kind.endswith("s"):
return self.kind.lower()
return f"{self.kind.lower()}s"
@cached_property @cached_property
def resource_definition(self): def resource_definition(self):
kind = self.service_definition.api_definition["kind"]
group = self.service_definition.api_definition["group"]
version = self.service_definition.api_definition["version"]
client = self.control_plane.get_kubernetes_client() client = self.control_plane.get_kubernetes_client()
extensions_api = kubernetes.client.ApiextensionsV1Api(client) extensions_api = kubernetes.client.ApiextensionsV1Api(client)
@ -395,10 +368,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
for crd in crds.items: for crd in crds.items:
if matching_crd: if matching_crd:
break break
if crd.spec.group == self.group: if crd.spec.group == group:
for served_version in crd.spec.versions: for served_version in crd.spec.versions:
if served_version.name == self.version and served_version.served: if served_version.name == version and served_version.served:
if crd.spec.names.kind == self.kind: if crd.spec.names.kind == kind:
matching_crd = crd matching_crd = crd
break break
return matching_crd return matching_crd
@ -409,13 +382,9 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
if result := cache.get(cache_key): if result := cache.get(cache_key):
return result return result
if not self.resource_definition: version = self.service_definition.api_definition["version"]
return
for v in self.resource_definition.spec.versions: for v in self.resource_definition.spec.versions:
if v.name == self.version: if v.name == version:
if not v.schema or not v.schema.open_apiv3_schema:
return
result = v.schema.open_apiv3_schema.to_dict() result = v.schema.open_apiv3_schema.to_dict()
timeout_seconds = 60 * 60 * 24 timeout_seconds = 60 * 60 * 24
cache.set(cache_key, result, timeout=timeout_seconds) cache.set(cache_key, result, timeout=timeout_seconds)
@ -425,13 +394,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def django_model(self): def django_model(self):
from servala.core.crd import generate_django_model from servala.core.crd import generate_django_model
if not self.resource_schema:
return
kwargs = { kwargs = {
"group": self.group, key: value
"version": self.version, for key, value in self.service_definition.api_definition.items()
"kind": self.kind, if key in ("group", "version", "kind")
} }
return generate_django_model(self.resource_schema, **kwargs) return generate_django_model(self.resource_schema, **kwargs)
@ -439,8 +405,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def model_form_class(self): def model_form_class(self):
from servala.core.crd import generate_model_form_class from servala.core.crd import generate_model_form_class
if not self.django_model:
return
return generate_model_form_class(self.django_model) return generate_model_form_class(self.django_model)
@ -533,20 +497,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
class urls(urlman.Urls): class urls(urlman.Urls):
base = "{self.organization.urls.instances}{self.name}/" base = "{self.organization.urls.instances}{self.name}/"
update = "{base}update/"
delete = "{base}delete/"
def _clear_kubernetes_caches(self):
"""Clears cached properties that depend on Kubernetes state."""
attrs = self.__dict__.keys()
for key in (
"kubernetes_object",
"spec",
"status_conditions",
"connection_credentials",
):
if key in attrs:
delattr(self, key)
@classmethod @classmethod
def create_instance(cls, name, organization, context, created_by, spec_data): def create_instance(cls, name, organization, context, created_by, spec_data):
@ -567,9 +517,12 @@ class ServiceInstance(ServalaModelMixin, models.Model):
) )
try: try:
group = context.service_definition.api_definition["group"]
version = context.service_definition.api_definition["version"]
kind = context.service_definition.api_definition["kind"]
create_data = { create_data = {
"apiVersion": f"{context.group}/{context.version}", "apiVersion": f"{group}/{version}",
"kind": context.kind, "kind": kind,
"metadata": { "metadata": {
"name": name, "name": name,
"namespace": organization.namespace, "namespace": organization.namespace,
@ -578,12 +531,18 @@ class ServiceInstance(ServalaModelMixin, models.Model):
} }
if label := context.control_plane.required_label: if label := context.control_plane.required_label:
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label} create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
api_instance = context.control_plane.custom_objects_api api_instance = client.CustomObjectsApi(
context.control_plane.get_kubernetes_client()
)
plural = kind.lower()
if not plural.endswith("s"):
plural = f"{plural}s"
api_instance.create_namespaced_custom_object( api_instance.create_namespaced_custom_object(
group=context.group, group=group,
version=context.version, version=version,
namespace=organization.namespace, namespace=organization.namespace,
plural=context.kind_plural, plural=plural,
body=create_data, body=create_data,
) )
except Exception as e: except Exception as e:
@ -597,194 +556,3 @@ class ServiceInstance(ServalaModelMixin, models.Model):
raise ValidationError(_("Kubernetes API error: {}").format(str(e))) raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
raise ValidationError(_("Error creating instance: {}").format(str(e))) raise ValidationError(_("Error creating instance: {}").format(str(e)))
return instance return instance
def update_spec(self, spec_data, updated_by):
try:
api_instance = self.context.control_plane.custom_objects_api
patch_body = {"spec": spec_data}
api_instance.patch_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
body=patch_body,
)
self._clear_kubernetes_caches()
self.save() # Updates updated_at timestamp
except ApiException as e:
if e.status == 404:
raise ValidationError(
_(
"Service instance not found in Kubernetes. It may have been deleted externally."
)
)
try:
error_body = json.loads(e.body)
reason = error_body.get("message", str(e))
raise ValidationError(
_("Kubernetes API error updating instance: {error}").format(
error=reason
)
)
except (ValueError, TypeError):
raise ValidationError(
_("Kubernetes API error updating instance: {error}").format(
error=str(e)
)
)
except Exception as e:
raise ValidationError(
_("Error updating instance: {error}").format(error=str(e))
)
@transaction.atomic
def delete_instance(self, user):
"""
Soft deletes the instance in Django and initiates deletion of the
corresponding Kubernetes custom resource.
"""
if self.is_deleted:
return
if (
self.spec.get("parameters", {})
.get("security", {})
.get("deletionProtection")
):
spec = copy.copy(self.spec)
spec["parameters"]["security"]["deletionProtection"] = False
self.update_spec(spec, user)
try:
api_instance = self.context.control_plane.custom_objects_api
api_instance.delete_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
body=client.V1DeleteOptions(),
)
except ApiException as e:
if e.status != 404:
# 404 is fine, the object was deleted already.
raise
self.is_deleted = True
self.deleted_at = timezone.now()
self.deleted_by = user
self.save(
update_fields=["is_deleted", "deleted_at", "deleted_by", "updated_at"]
)
self._clear_kubernetes_caches()
@cached_property
def kubernetes_object(self):
"""Fetch the Kubernetes custom resource object"""
if self.is_deleted:
return
try:
api_instance = client.CustomObjectsApi(
self.context.control_plane.get_kubernetes_client()
)
return api_instance.get_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
)
except ApiException as e:
if e.status == 404:
return None
raise
@cached_property
def spec(self):
if not self.kubernetes_object:
return {}
if not (spec := self.kubernetes_object.get("spec")):
return {}
# Remove fields that shouldn't be displayed
spec = spec.copy()
spec.pop("resourceRef", None)
spec.pop("writeConnectionSecretToRef", None)
return spec
@cached_property
def spec_object(self):
"""Dynamically generated CRD object."""
if not self.context.django_model:
return
return self.context.django_model(
name=self.name,
organization=self.organization,
context=self.context,
spec=self.spec,
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
# and b) its 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)}

View file

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from servala.core.models import ( from servala.core.models import (
@ -7,7 +6,6 @@ from servala.core.models import (
ControlPlane, ControlPlane,
Service, Service,
ServiceCategory, ServiceCategory,
ServiceInstance,
ServiceOffering, ServiceOffering,
) )
@ -19,17 +17,13 @@ class ServiceFilterForm(forms.Form):
cloud_provider = forms.ModelChoiceField( cloud_provider = forms.ModelChoiceField(
queryset=CloudProvider.objects.all(), required=False queryset=CloudProvider.objects.all(), required=False
) )
q = forms.CharField(label=_("Search"), required=False) q = forms.CharField(required=False)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"): if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category) queryset = queryset.filter(category=category)
if cloud_provider := self.cleaned_data.get("cloud_provider"): if cloud_provider := self.cleaned_data.get("cloud_provider"):
queryset = queryset.filter(offerings__provider=cloud_provider) queryset = queryset.filter(offerings__provider=cloud_provider)
if search := self.cleaned_data.get("q"):
queryset = queryset.filter(
Q(name__icontains=search) | Q(category__name__icontains=search)
)
return queryset return queryset
@ -43,8 +37,6 @@ class ControlPlaneSelectForm(forms.Form):
def __init__(self, *args, planes=None, **kwargs): def __init__(self, *args, planes=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["control_plane"].queryset = planes self.fields["control_plane"].queryset = planes
if planes and planes.count() == 1:
self.fields["control_plane"].initial = planes.first()
class ServiceInstanceFilterForm(forms.Form): class ServiceInstanceFilterForm(forms.Form):
@ -76,50 +68,21 @@ class ServiceInstanceFilterForm(forms.Form):
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
if self.is_valid(): if self.is_valid():
data = self.cleaned_data data = self.cleaned_data
if data.get("name"): if data["name"]:
queryset = queryset.filter(name__icontains=data["name"]) queryset = queryset.filter(name__icontains=data["name"])
if data.get("service"): if data["service"]:
queryset = queryset.filter( queryset = queryset.filter(
context__service_definition__service=data["service"] context__service_definition__service=data["service"]
) )
if data.get("provider"): if data["provider"]:
queryset = queryset.filter( queryset = queryset.filter(
context__service_offering__provider=data["provider"] context__service_offering__provider=data["provider"]
) )
if data.get("control_plane"): if data["control_plane"]:
queryset = queryset.filter(context__control_plane=data["control_plane"]) queryset = queryset.filter(context__control_plane=data["control_plane"])
status = data.get("status") if data["status"]:
if status == "active": if data["status"] == "active":
queryset = queryset.filter(is_deleted=False) queryset = queryset.filter(is_deleted=False)
elif status == "deleted": else:
queryset = queryset.filter(is_deleted=True) queryset = queryset.filter(is_deleted=True)
return queryset return queryset
class ServiceInstanceDeleteForm(forms.ModelForm):
name = forms.CharField(
label=_("Instance Name"),
max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}),
)
def __init__(self, *args, **kwargs):
kwargs["initial"] = {"name": ""}
super().__init__(*args, **kwargs)
self.fields["name"].help_text = _(
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>"
).format(instance_name=self.instance.name)
def clean_name(self):
entered_name = self.cleaned_data.get("name")
if entered_name != self.instance.name:
raise forms.ValidationError(
_(
"The entered name does not match the instance name. Deletion not confirmed."
)
)
return entered_name
class Meta:
model = ServiceInstance
fields = ("name",)

View file

@ -25,13 +25,9 @@
<div class="content-wrapper container"> <div class="content-wrapper container">
<div class="page-heading"> <div class="page-heading">
<h3> <h3>
<span>
{% block page_title %} {% block page_title %}
Dashboard Dashboard
{% endblock page_title %} {% endblock page_title %}
</span>
{% block page_title_extra %}
{% endblock page_title_extra %}
</h3> </h3>
</div> </div>
<div class="page-content"> <div class="page-content">

View file

@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
{% if form.non_field_errors or form.errors %} {% if form.non_field_errors or form.errors %}
<div class="alert alert-danger form-errors" role="alert"> <div class="alert alert-danger" role="alert">
<div> <div>
{% if form.non_field_errors %} {% if form.non_field_errors %}
{% if form.non_field_errors|length > 1 %} {% if form.non_field_errors|length > 1 %}

View file

@ -28,10 +28,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
{% for offering in service.offerings.all %} {% for offering in service.offerings.all %}
<div class="col-6 col-lg-3 col-md-4"> <div class="card col-6 col-lg-3 col-md-4">
<div class="card">
<div class="card-header d-flex align-items-center"> <div class="card-header d-flex align-items-center">
{% if offering.provider.logo %} {% if offering.provider.logo %}
<img src="{{ offering.provider.logo.url }}" <img src="{{ offering.provider.logo.url }}"
@ -56,7 +54,6 @@
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a> <a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div> </div>
</div> </div>
</div>
{% empty %} {% empty %}
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@ -64,6 +61,5 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
</section> </section>
{% endblock content %} {% endblock content %}

View file

@ -1,25 +0,0 @@
{% 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>

View file

@ -1,34 +1,17 @@
{% extends "frontend/base.html" %} {% extends "frontend/base.html" %}
{% load i18n static pprint_filters %} {% load i18n static %}
{% block html_title %} {% block html_title %}
{% block page_title %} {% block page_title %}
{{ instance.name }} {{ instance.name }}
{% endblock page_title %} {% endblock page_title %}
{% endblock html_title %} {% endblock html_title %}
{% block page_title_extra %}
<div>
{% if has_change_permission and not instance.is_deleted %}
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
{% endif %}
{% if has_delete_permission and not instance.is_deleted %}
<button type="button"
class="btn btn-danger me-1 mb-1"
hx-get="{{ instance.urls.delete }}"
hx-target="#deleteInstanceModalContent"
data-bs-toggle="modal"
data-bs-target="#deleteInstanceModal">{% translate "Delete" %}</button>
{% endif %}
</div>
{% endblock page_title_extra %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="row">
<div class="col-12 col-md-5">
<div class="card"> <div class="card">
<div class="card-header">
<h4>{% translate "Details" %}</h4>
</div>
<div class="card-body"> <div class="card-body">
<div class="row">
<div class="col-md-6">
<h5>{% translate "Details" %}</h5>
<dl class="row"> <dl class="row">
<dt class="col-sm-4">{% translate "Service" %}</dt> <dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
@ -44,7 +27,7 @@
</dd> </dd>
<dt class="col-sm-4">{% translate "Created By" %}</dt> <dt class="col-sm-4">{% translate "Created By" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{{ instance.created_by|default:"-" }} {{ instance.created_by }}
</dd> </dd>
<dt class="col-sm-4">{% translate "Created At" %}</dt> <dt class="col-sm-4">{% translate "Created At" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
@ -57,14 +40,7 @@
<dt class="col-sm-4">{% translate "Status" %}</dt> <dt class="col-sm-4">{% translate "Status" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{% if instance.is_deleted %} {% if instance.is_deleted %}
<span class="badge text-bg-danger">{% translate "Deleted" %}</span> <span class="badge text-bg-secondary">{% translate "Deleted" %}</span>
{% if instance.deleted_at %}
<small class="text-muted d-block mt-1">
{% blocktranslate with date=instance.deleted_at|date:"SHORT_DATETIME_FORMAT" user=instance.deleted_by|default:_("system") %}
On {{ date }} by {{ user }}
{% endblocktranslate %}
</small>
{% endif %}
{% else %} {% else %}
<span class="badge text-bg-success">{% translate "Active" %}</span> <span class="badge text-bg-success">{% translate "Active" %}</span>
{% endif %} {% endif %}
@ -73,181 +49,6 @@
</div> </div>
</div> </div>
</div> </div>
{% if not instance.is_deleted and instance.status_conditions %}
<div class="col-12 col-md-7">
<div class="card">
<div class="card-header">
<h4>{% translate "Status" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Type" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Last Transition Time" %}</th>
<th>{% translate "Reason" %}</th>
<th>{% translate "Message" %}</th>
</tr>
</thead>
<tbody>
{% for condition in instance.status_conditions %}
<tr>
<td>{{ condition.type }}</td>
<td>
{% if condition.status == "True" %}
<span class="badge text-bg-success">True</span>
{% elif condition.status == "False" %}
<span class="badge text-bg-danger">False</span>
{% else %}
<span class="badge text-bg-secondary">{{ condition.status }}</span>
{% endif %}
</td>
<td>{{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ condition.reason|default:"-" }}</td>
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if not instance.is_deleted and instance.spec and spec_fieldsets %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Specification" %}</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-12">
<!-- Tabs -->
<ul class="nav nav-tabs" role="tablist">
{% for fieldset in spec_fieldsets %}
<li class="nav-item" role="presentation">
<a href="#spec-tab-{{ forloop.counter }}"
class="nav-link {% if forloop.first %}active{% endif %}"
data-bs-toggle="tab"
role="tab">{{ fieldset.title }}</a>
</li>
{% empty %}
<li class="nav-item ms-2">{% translate "No specification details available." %}</li>
{% endfor %}
</ul>
<!-- Tab Content -->
<div class="tab-content pt-3">
{% for fieldset in spec_fieldsets %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
id="spec-tab-{{ forloop.counter }}"
role="tabpanel">
<!-- Main Fields -->
<dl class="row">
{% for field in fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt>
<dd class="col-sm-9">
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
<pre>{{ field.value|pprint }}</pre>
{% else %}
{{ field.value|default:"-" }}
{% endif %}
</dd>
{% endfor %}
</dl>
<!-- Nested Fieldsets -->
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
<h5>{{ sub_fieldset.title }}</h5>
<dl class="row">
{% for field in sub_fieldset.fields %}
<dt class="col-sm-3">{{ field.label }}</dt>
<dd class="col-sm-9">
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
<pre>{{ field.value|pprint }}</pre>
{% else %}
{{ field.value|default:"-" }}
{% endif %}
</dd>
{% endfor %}
</dl>
{% endfor %}
</div>
{% empty %}
<p>{% translate "No specification details to display." %}</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if instance.connection_credentials %}
<div class="card">
<div class="card-header">
<h4>{% translate "Connection Credentials" %}</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</th>
</tr>
</thead>
<tbody>
{% for key, value in instance.connection_credentials.items %}
<tr>
<td>{{ key }}</td>
<td>
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code>{{ value }}</code>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div> </div>
</section> </section>
<!-- Delete Confirmation Modal -->
<div class="modal fade"
id="deleteInstanceModal"
tabindex="-1"
aria-labelledby="deleteInstanceModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" id="deleteInstanceModalContent">
{# Content will be loaded here by HTMX #}
<div class="modal-header">
<h5 class="modal-title" id="deleteInstanceModalLabel">{% translate "Confirm Deletion" %}</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">{% translate "Loading..." %}</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Cancel" %}</button>
</div>
</div>
</div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -1,43 +0,0 @@
{% 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 %}

View file

@ -16,10 +16,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
{% for service in services %} {% for service in services %}
<div class="col-6 col-lg-3 col-md-4"> <div class="card col-6 col-lg-3 col-md-4">
<div class="card">
<div class="card-header d-flex align-items-center"> <div class="card-header d-flex align-items-center">
{% if service.logo %} {% if service.logo %}
<img src="{{ service.logo.url }}" <img src="{{ service.logo.url }}"
@ -41,7 +39,6 @@
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a> <a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div> </div>
</div> </div>
</div>
{% empty %} {% empty %}
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@ -49,7 +46,6 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
</section> </section>
<script src="{% static "js/autosubmit.js" %}" defer></script> <script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %} {% endblock content %}

View file

@ -6,7 +6,6 @@
{% csrf_token %} {% csrf_token %}
<ul class="nav nav-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %} {% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" <button class="nav-link {% if forloop.first %}active{% endif %}"
id="{{ fieldset.title|slugify }}-tab" id="{{ fieldset.title|slugify }}-tab"
@ -19,12 +18,11 @@
{{ fieldset.title }} {{ fieldset.title }}
</button> </button>
</li> </li>
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="tab-content" id="myTabContent"> <div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %} {% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}" <div class="tab-pane fade my-2 {% if forloop.first %}show active{% endif %}"
id="{{ fieldset.title|slugify }}" id="{{ fieldset.title|slugify }}"
role="tabpanel" role="tabpanel"
aria-labelledby="{{ fieldset.title|slugify }}-tab"> aria-labelledby="{{ fieldset.title|slugify }}-tab">

View file

@ -1,12 +0,0 @@
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

View file

@ -50,16 +50,6 @@ urlpatterns = [
views.ServiceInstanceDetailView.as_view(), views.ServiceInstanceDetailView.as_view(),
name="organization.instance", name="organization.instance",
), ),
path(
"instances/<slug:slug>/update/",
views.ServiceInstanceUpdateView.as_view(),
name="organization.instance.update",
),
path(
"instances/<slug:slug>/delete/",
views.ServiceInstanceDeleteView.as_view(),
name="organization.instance.delete",
),
] ]
), ),
), ),

View file

@ -7,10 +7,8 @@ from .organization import (
) )
from .service import ( from .service import (
ServiceDetailView, ServiceDetailView,
ServiceInstanceDeleteView,
ServiceInstanceDetailView, ServiceInstanceDetailView,
ServiceInstanceListView, ServiceInstanceListView,
ServiceInstanceUpdateView,
ServiceListView, ServiceListView,
ServiceOfferingDetailView, ServiceOfferingDetailView,
) )
@ -22,10 +20,8 @@ __all__ = [
"OrganizationDashboardView", "OrganizationDashboardView",
"OrganizationUpdateView", "OrganizationUpdateView",
"ServiceDetailView", "ServiceDetailView",
"ServiceInstanceDeleteView",
"ServiceInstanceDetailView", "ServiceInstanceDetailView",
"ServiceInstanceListView", "ServiceInstanceListView",
"ServiceInstanceUpdateView",
"ServiceListView", "ServiceListView",
"ServiceOfferingDetailView", "ServiceOfferingDetailView",
"ProfileView", "ProfileView",

View file

@ -1,12 +1,9 @@
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView
from django.views.generic import DetailView, ListView, UpdateView
from servala.core.crd import deslugify
from servala.core.models import ( from servala.core.models import (
ControlPlaneCRD, ControlPlaneCRD,
Service, Service,
@ -16,14 +13,9 @@ from servala.core.models import (
from servala.frontend.forms.service import ( from servala.frontend.forms.service import (
ControlPlaneSelectForm, ControlPlaneSelectForm,
ServiceFilterForm, ServiceFilterForm,
ServiceInstanceDeleteForm,
ServiceInstanceFilterForm, ServiceInstanceFilterForm,
) )
from servala.frontend.views.mixins import ( from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
HtmxUpdateView,
HtmxViewMixin,
OrganizationViewMixin,
)
class ServiceListView(OrganizationViewMixin, ListView): class ServiceListView(OrganizationViewMixin, ListView):
@ -36,14 +28,10 @@ class ServiceListView(OrganizationViewMixin, ListView):
def get_queryset(self): def get_queryset(self):
"""Return all services.""" """Return all services."""
services = ( services = Service.objects.all().select_related("category")
Service.objects.all()
.select_related("category")
.prefetch_related("offerings__provider")
)
if self.filter_form.is_valid(): if self.filter_form.is_valid():
services = self.filter_form.filter_queryset(services) services = self.filter_form.filter_queryset(services)
return services.distinct() return services
@cached_property @cached_property
def filter_form(self): def filter_form(self):
@ -98,7 +86,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
@cached_property @cached_property
def selected_plane(self): def selected_plane(self):
if self.select_form.is_valid() and self.select_form.cleaned_data: if self.select_form.data and self.select_form.is_valid():
return self.select_form.cleaned_data["control_plane"] return self.select_form.cleaned_data["control_plane"]
field = self.select_form.fields["control_plane"] field = self.select_form.fields["control_plane"]
return field.initial or field.queryset.first() return field.initial or field.queryset.first()
@ -116,8 +104,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
).first() ).first()
def get_instance_form(self): def get_instance_form(self):
if not self.context_object or not self.context_object.model_form_class:
return None
return self.context_object.model_form_class( return self.context_object.model_form_class(
data=self.request.POST if self.request.method == "POST" else None, data=self.request.POST if self.request.method == "POST" else None,
initial={ initial={
@ -143,14 +129,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
return self.render_to_response(context) return self.render_to_response(context)
form = self.get_instance_form() form = self.get_instance_form()
if not form: # Should not happen if context_object is valid, but as a safeguard
messages.error(self.request, _("Could not initialize service form."))
return self.render_to_response(context)
if form.is_valid(): if form.is_valid():
try: try:
service_instance = ServiceInstance.create_instance( service_instance = ServiceInstance.create_instance(
organization=self.request.organization, organization=self.organization,
name=form.cleaned_data["name"], name=form.cleaned_data["name"],
context=self.context_object, context=self.context_object,
created_by=request.user, created_by=request.user,
@ -160,23 +142,21 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
except ValidationError as e: except ValidationError as e:
messages.error(self.request, e.message or str(e)) messages.error(self.request, e.message or str(e))
except Exception as e: except Exception as e:
messages.error( messages.error(self.request, str(e))
self.request, _("Error creating instance: {}").format(str(e))
)
# If the form is not valid or if the service creation failed, we render it again # If the form is not valid or if the service creation failed, we render it again
context["service_form"] = form context["service_form"] = form
return self.render_to_response(context) return self.render_to_response(context)
class ServiceInstanceMixin: class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
model = ServiceInstance """View to display details of a specific service instance."""
context_object_name = "instance"
slug_field = "name"
def dispatch(self, *args, **kwargs): template_name = "frontend/organizations/service_instance_detail.html"
self._has_warned = False context_object_name = "instance"
return super().dispatch(*args, **kwargs) model = ServiceInstance
permission_type = "view"
slug_field = "name"
def get_queryset(self): def get_queryset(self):
"""Return service instance for the current organization.""" """Return service instance for the current organization."""
@ -188,187 +168,6 @@ class ServiceInstanceMixin:
"context__service_definition__service", "context__service_definition__service",
) )
def get_object(self, **kwargs):
instance = super().get_object(**kwargs)
if (
not instance.is_deleted
and not instance.kubernetes_object
and not self._has_warned
):
messages.warning(
self.request,
_(
"Could not retrieve instance details from Kubernetes. It might have been deleted externally."
),
)
self._has_warned = True
return instance
class ServiceInstanceDetailView(
ServiceInstanceMixin, OrganizationViewMixin, DetailView
):
template_name = "frontend/organizations/service_instance_detail.html"
permission_type = "view"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if (
not self.object.is_deleted
and self.object.kubernetes_object
and self.object.spec
):
context["spec_fieldsets"] = self.get_nested_spec()
context["has_change_permission"] = self.request.user.has_perm(
ServiceInstance.get_perm("change"), self.object
)
context["has_delete_permission"] = self.request.user.has_perm(
ServiceInstance.get_perm("delete"), self.object
)
return context
def get_nested_spec(self):
"""
Organize spec data into fieldsets similar to how the form does it.
"""
spec = self.object.spec or {}
if not spec:
return []
others = []
nested_fieldsets = {}
# First pass: organize fields into nested structures
for key, value in spec.items():
if isinstance(value, dict):
# This is a nested structure
if key not in nested_fieldsets:
nested_fieldsets[key] = {
"title": deslugify(key),
"fields": [],
"fieldsets": {},
}
# Process fields in the nested structure
for sub_key, sub_value in value.items():
if isinstance(sub_value, dict):
# Even deeper nesting
if sub_key not in nested_fieldsets[key]["fieldsets"]:
nested_fieldsets[key]["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
# Add fields from the deeper level
for leaf_key, leaf_value in sub_value.items():
nested_fieldsets[key]["fieldsets"][sub_key][
"fields"
].append(
{
"key": leaf_key,
"label": deslugify(leaf_key),
"value": leaf_value,
}
)
else:
# Add field to parent level
nested_fieldsets[key]["fields"].append(
{
"key": sub_key,
"label": deslugify(sub_key),
"value": sub_value,
}
)
else:
# This is a top-level field
others.append(
{
"key": key,
"label": deslugify(key),
"value": value,
}
)
# Second pass: Promote fields based on count
for group_key, group in list(nested_fieldsets.items()):
# Promote single sub-fieldsets to parent
for sub_key, sub_fieldset in list(group["fieldsets"].items()):
if len(sub_fieldset["fields"]) == 1:
field = sub_fieldset["fields"][0]
field["label"] = f"{sub_fieldset['title']}: {field['label']}"
group["fields"].append(field)
del group["fieldsets"][sub_key]
# Move single-field groups to others
total_fields = len(group["fields"])
for sub_fieldset in group["fieldsets"].values():
total_fields += len(sub_fieldset["fields"])
if (
total_fields == 1
and len(group["fields"]) == 1
and not group["fieldsets"]
):
field = group["fields"][0]
field["label"] = f"{group['title']}: {field['label']}"
others.append(field)
del nested_fieldsets[group_key]
elif total_fields == 0:
del nested_fieldsets[group_key]
fieldsets = []
if others:
fieldsets.append(
{
"title": "General",
"fields": others,
"fieldsets": {},
}
)
# Create fieldsets from the organized data
for group in nested_fieldsets.values():
fieldsets.append(group)
return fieldsets
class ServiceInstanceUpdateView(
ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView
):
template_name = "frontend/organizations/service_instance_update.html"
permission_type = "change"
def get_form_class(self):
return self.object.context.model_form_class
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["instance"] = self.object.spec_object
return kwargs
def form_valid(self, form):
try:
spec_data = form.get_nested_data().get("spec")
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
messages.success(
self.request,
_("Service instance '{name}' updated successfully.").format(
name=self.object.name
),
)
return redirect(self.object.urls.base)
except ValidationError as e:
messages.error(self.request, e.message or str(e))
return self.form_invalid(form)
except Exception as e:
messages.error(
self.request, _("Error updating instance: {error}").format(error=str(e))
)
return self.form_invalid(form)
def get_success_url(self):
return self.object.urls.base
class ServiceInstanceListView(OrganizationViewMixin, ListView): class ServiceInstanceListView(OrganizationViewMixin, ListView):
template_name = "frontend/organizations/service_instances.html" template_name = "frontend/organizations/service_instances.html"
@ -383,61 +182,14 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView):
def get_queryset(self): def get_queryset(self):
"""Return all service instances for the current organization with filtering.""" """Return all service instances for the current organization with filtering."""
queryset = ServiceInstance.objects.filter( queryset = ServiceInstance.objects.filter(
organization=self.request.organization, organization=self.request.organization
).select_related(
"context__service_offering__provider",
"context__control_plane",
"context__service_definition__service",
) )
if self.filter_form.is_valid(): if self.filter_form.is_valid():
queryset = self.filter_form.filter_queryset(queryset) queryset = self.filter_form.filter_queryset(queryset)
status_filter = ( return queryset
self.filter_form.cleaned_data.get("status")
if self.filter_form.is_valid()
else "active"
)
if status_filter == "active":
queryset = queryset.filter(is_deleted=False)
elif status_filter == "deleted":
queryset = queryset.filter(is_deleted=True)
return queryset.order_by("-created_at")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["organization"] = self.request.organization context["organization"] = self.request.organization
context["filter_form"] = self.filter_form context["filter_form"] = self.filter_form
return context return context
class ServiceInstanceDeleteView(
ServiceInstanceMixin, OrganizationViewMixin, HtmxViewMixin, UpdateView
):
template_name = "frontend/organizations/service_instance_delete_form.html"
form_class = ServiceInstanceDeleteForm
permission_type = "delete"
def form_valid(self, form):
try:
self.object.delete_instance(user=self.request.user)
messages.success(
self.request,
_("Service instance '{name}' has been scheduled for deletion.").format(
name=self.object.name
),
)
response = HttpResponse()
response["HX-Redirect"] = self.get_success_url()
return response
except Exception as e:
messages.error(
self.request,
_(
"An error occurred while trying to delete instance '{name}': {error}"
).format(name=self.object.name, error=str(e)),
)
response = HttpResponse()
response["HX-Redirect"] = str(self.object.urls.base)
return response
def get_success_url(self):
return str(self.request.organization.urls.instances)

View file

@ -10,12 +10,9 @@ Servala is run using environment variables. Documentation:
""" """
import os import os
import sentry_sdk
from pathlib import Path from pathlib import Path
from sentry_sdk.integrations.django import DjangoIntegration
from django.contrib import messages from django.contrib import messages
from servala.__about__ import __version__ as version
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development") SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
DEBUG = SERVALA_ENVIRONMENT == "development" DEBUG = SERVALA_ENVIRONMENT == "development"
@ -90,42 +87,6 @@ SOCIALACCOUNT_PROVIDERS = {
} }
} }
SERVALA_STORAGE_BUCKET_NAME = os.environ.get("SERVALA_STORAGE_BUCKET_NAME")
SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL")
SERVALA_ACCESS_KEY_ID = os.environ.get("SERVALA_ACCESS_KEY_ID")
SERVALA_SECRET_ACCESS_KEY = os.environ.get("SERVALA_SECRET_ACCESS_KEY")
SERVALA_S3_REGION_NAME = os.environ.get("SERVALA_S3_REGION_NAME", "eu-central-1")
SERVALA_S3_ADDRESSING_STYLE = os.environ.get("SERVALA_S3_ADDRESSING_STYLE", "virtual")
SERVALA_S3_SIGNATURE_VERSION = os.environ.get("SERVALA_S3_SIGNATURE_VERSION", "s3v4")
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
if all(
[
SERVALA_STORAGE_BUCKET_NAME,
SERVALA_S3_ENDPOINT_URL,
SERVALA_ACCESS_KEY_ID,
SERVALA_SECRET_ACCESS_KEY,
]
):
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": SERVALA_STORAGE_BUCKET_NAME,
"endpoint_url": SERVALA_S3_ENDPOINT_URL,
"access_key": SERVALA_ACCESS_KEY_ID,
"secret_key": SERVALA_SECRET_ACCESS_KEY,
"region_name": SERVALA_S3_REGION_NAME,
"addressing_style": SERVALA_S3_ADDRESSING_STYLE,
"signature_version": SERVALA_S3_SIGNATURE_VERSION,
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
####################################### #######################################
# Non-configurable settings below # # Non-configurable settings below #
####################################### #######################################
@ -257,13 +218,3 @@ TIME_ZONE = "UTC"
if SERVALA_ENVIRONMENT in ("staging", "production"): if SERVALA_ENVIRONMENT in ("staging", "production"):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SERVALA_SENTRY_DSN = os.environ.get("SERVALA_SENTRY_DSN")
sentry_sdk.init(
dsn=SERVALA_SENTRY_DSN,
integrations=[DjangoIntegration()],
auto_session_tracking=False,
traces_sample_rate=0.01,
release=version,
environment=SERVALA_ENVIRONMENT,
)

View file

@ -85,11 +85,3 @@ html[data-bs-theme="dark"] .btn-outline-primary, .btn-outline-primary {
a.btn-keycloak { a.btn-keycloak {
display: inline-flex; display: inline-flex;
} }
.page-heading h3 {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.hide-form-errors .alert.form-errors {
display: none;
}

846
uv.lock generated

File diff suppressed because it is too large Load diff