Compare commits

...

111 commits

Author SHA1 Message Date
2f4e6a4494 Merge pull request 'Update dependency django to v5.2.2' (#93) from renovate/django-5.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m12s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #93
2025-06-05 07:34:14 +00:00
d4f24c9166 Merge pull request 'Update Python to >=3.13.4' (#94) from renovate/python-3.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #94
2025-06-05 07:34:07 +00:00
Renovate Bot
7c2f1b74d5 Update Python to >=3.13.4
All checks were successful
Tests / test (push) Successful in 24s
2025-06-05 03:01:40 +00:00
Renovate Bot
d306cbe52b Update dependency django to v5.2.2
All checks were successful
Tests / test (push) Successful in 24s
2025-06-05 03:01:35 +00:00
f38db4541d Merge pull request 'Integrate Sentry error reporting' (#92) from sentry into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m0s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 11s
Reviewed-on: #92
2025-06-04 08:35:39 +00:00
8016c7a5ad Merge pull request 'Update dependency argon2-cffi to v25' (#90) from renovate/argon2-cffi-25.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 51s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 8s
Reviewed-on: #90
2025-06-04 06:54:47 +00:00
3e1d6d6947 Merge pull request 'Update dependency pytest to >=8.4.0' (#89) from renovate/pytest-8.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #89
2025-06-04 06:54:29 +00:00
8e12688597
integrate sentry error reporting
All checks were successful
Tests / test (push) Successful in 26s
2025-06-04 08:46:20 +02:00
Renovate Bot
4df4bc36fa Update dependency argon2-cffi to v25
All checks were successful
Tests / test (push) Successful in 24s
2025-06-04 03:01:17 +00:00
Renovate Bot
b088f40563 Update dependency pytest to >=8.4.0
All checks were successful
Tests / test (push) Successful in 23s
2025-06-04 03:01:10 +00:00
bbbe390238 Merge pull request 'Update python Docker tag' (#71) from renovate/python-3.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 50s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 8s
Reviewed-on: #71
2025-06-03 05:16:16 +00:00
917dd31b57 Merge pull request 'Update dependency django-allauth to >=65.9.0' (#76) from renovate/django-allauth-65.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #76
2025-06-03 05:15:39 +00:00
b6fb21eb60 Merge pull request 'Update dependency pytest-cov to >=6.1.1' (#80) from renovate/pytest-cov-6.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Build and Deploy Staging / build (push) Successful in 59s
Tests / test (push) Has been cancelled
Reviewed-on: #80
2025-06-03 05:14:13 +00:00
d31850ef90 Merge pull request 'Update dependency cryptography to >=45.0.3' (#83) from renovate/cryptography-45.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #83
2025-06-03 05:13:55 +00:00
Renovate Bot
b3a59f243c Update python Docker tag
All checks were successful
Tests / test (push) Successful in 23s
2025-06-03 03:02:09 +00:00
Renovate Bot
3844092231 Update dependency pytest-cov to >=6.1.1
All checks were successful
Tests / test (push) Successful in 23s
2025-06-03 03:01:56 +00:00
Renovate Bot
02abc2c60c Update dependency django-allauth to >=65.9.0
All checks were successful
Tests / test (push) Successful in 22s
2025-06-03 03:01:51 +00:00
Renovate Bot
316fd17ae9 Update dependency cryptography to >=45.0.3
All checks were successful
Tests / test (push) Successful in 23s
2025-06-03 03:01:46 +00:00
75f34b8174 Update lockfile
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m11s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 7s
closes #87
2025-06-02 14:57:28 +02:00
4deb7ab58f Merge pull request 'Update dependency jsonschema to >=4.24.0' (#78) from renovate/jsonschema-4.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 56s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 9s
Reviewed-on: #78
2025-06-02 12:55:24 +00:00
e3af3ddfec Merge pull request 'Update dependency coverage to >=7.8.2' (#75) from renovate/coverage-7.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #75
2025-06-02 12:54:52 +00:00
1892d27475 Merge pull request 'Update dependency flake8 to >=7.2.0' (#77) from renovate/flake8-7.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #77
2025-06-02 12:54:30 +00:00
de0cb1b885 Merge pull request 'Update dependency pillow to >=11.2.1' (#79) from renovate/pillow-11.x into main
Some checks are pending
Build and Deploy Staging / build (push) Waiting to run
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Reviewed-on: #79
2025-06-02 12:54:11 +00:00
3c36b89584 Merge pull request 'Update dependency pytest-django to >=4.11.1' (#81) from renovate/pytest-django-4.x into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #81
2025-06-02 12:53:16 +00:00
a37b0d4a13 Merge pull request 'Display/CSS fixes' (#88) from css into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m8s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #88
2025-06-02 08:37:04 +00:00
fa6ac5334e Fix search field functionality
All checks were successful
Tests / test (push) Successful in 24s
2025-06-02 10:35:27 +02:00
cb7332f4e9 Fix display of search field 2025-06-02 10:35:22 +02:00
50a7f628e4 Display fix: Place service offerings in rows 2025-06-02 10:32:12 +02:00
c3d8fd9f56 Display fix: place services in rows 2025-06-02 10:31:07 +02:00
c19b73eb07 Merge pull request 'Update dependency django to v5.2.1' (#70) from renovate/django-5.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 2m27s
Tests / test (push) Successful in 38s
Build and Deploy Staging / deploy (push) Successful in 41s
Reviewed-on: #70
2025-06-02 07:35:08 +00:00
8566520f97 Merge pull request 'Update docker/build-push-action action to v6' (#85) from renovate/docker-build-push-action-6.x into main
Reviewed-on: #85
2025-06-02 07:34:41 +00:00
f480711f4e Merge pull request 'Update https://github.com/astral-sh/setup-uv action to v6' (#86) from renovate/https-github.com-astral-sh-setup-uv-6.x into main
Reviewed-on: #86
2025-06-02 07:34:29 +00:00
b146a6e59f Merge pull request 'Update dependency node to v22' (#84) from renovate/node-22.x into main
Reviewed-on: #84
2025-06-02 07:34:13 +00:00
d5a7133b23 Merge pull request 'Update quay.io/appuio/oc Docker tag to v4.18' (#82) from renovate/quay.io-appuio-oc-4.x into main
Reviewed-on: #82
2025-06-02 07:33:51 +00:00
Renovate Bot
79a1c4dc45 Update https://github.com/astral-sh/setup-uv action to v6 2025-05-27 13:17:11 +00:00
Renovate Bot
9ee826eb50 Update docker/build-push-action action to v6 2025-05-27 13:17:06 +00:00
Renovate Bot
891519e97b Update dependency node to v22 2025-05-27 13:16:58 +00:00
Renovate Bot
fd8c21895e Update quay.io/appuio/oc Docker tag to v4.18 2025-05-27 13:16:47 +00:00
Renovate Bot
1b3239db3e Update dependency pytest-django to >=4.11.1
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:16:32 +00:00
Renovate Bot
0312b5d2cc Update dependency pillow to >=11.2.1
All checks were successful
Tests / test (push) Successful in 24s
2025-05-27 13:16:18 +00:00
Renovate Bot
cc27d74482 Update dependency jsonschema to >=4.24.0
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:16:11 +00:00
Renovate Bot
8dfbafb230 Update dependency flake8 to >=7.2.0
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:16:04 +00:00
Renovate Bot
68c9f75684 Update dependency coverage to >=7.8.2
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:15:51 +00:00
Renovate Bot
87838a38c3 Update dependency django to v5.2.1
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:15:42 +00:00
15370f9739 Merge pull request 'specify storage for static files' (#69) from staticfiles-storage into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m9s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #69
2025-05-27 13:15:03 +00:00
880b38ce3f
remove wrong secret ref
This becomes annoying - Claude really didn't help with Renovate
2025-05-27 15:11:51 +02:00
4d5c8e3784
remove invalid setting from renovate config 2025-05-27 15:08:05 +02:00
e0a1197a70
adapt renovate config to recommendations 2025-05-27 15:03:17 +02:00
3976d2905b
define the github token for renovate 2025-05-27 14:58:00 +02:00
67a76e7f4c
specify which repo to renovate 2025-05-27 14:48:40 +02:00
995ace7d97
set the platform in renovate config 2025-05-27 14:09:02 +02:00
45a1825b70
remove config file location definition 2025-05-27 14:02:30 +02:00
06efd09f60
only execute python tests on src changes 2025-05-27 13:53:38 +02:00
52553796d3
restrict workflows to certain paths
Some checks failed
Tests / test (push) Has been cancelled
2025-05-27 13:52:24 +02:00
378f10c992
enhanced renovate config
Some checks failed
Tests / test (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Build and Deploy Staging / build (push) Has been cancelled
2025-05-27 13:44:38 +02:00
313a8a5492
use better container for renovate job
Some checks failed
Build and Deploy Staging / deploy (push) Has been cancelled
Build and Deploy Staging / build (push) Has been cancelled
Tests / test (push) Has been cancelled
2025-05-27 13:37:43 +02:00
8edb059831
specify storage for static files
All checks were successful
Tests / test (push) Successful in 24s
2025-05-27 13:34:16 +02:00
4ef2f9a31b
use full url for action location
Some checks failed
Build and Deploy Staging / deploy (push) Has been cancelled
Build and Deploy Staging / build (push) Has been cancelled
Tests / test (push) Has been cancelled
2025-05-27 13:27:29 +02:00
a6a617f229 Merge pull request 'configure renovate with a recurring action' (#68) from renovate-config into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m3s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 9s
Reviewed-on: #68
2025-05-27 11:21:23 +00:00
126ff35065
configure renovate with a recurring action
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:20:45 +02:00
1dda974e11
Revert "configure django storages with env vars"
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m2s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 8s
This reverts commit 5f38856dd9.
2025-05-27 11:25:39 +02:00
5f38856dd9
configure django storages with env vars
All checks were successful
Build and Deploy Staging / build (push) Successful in 55s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 8s
2025-05-27 11:18:56 +02:00
eb73b35a5c
add objectstorage to deployment
All checks were successful
Build and Deploy Staging / build (push) Successful in 56s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 10s
2025-05-27 11:11:40 +02:00
d8cc90188e Merge pull request 'Fix instance creation' (#65) from 63-instance-creation-bug into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 57s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 9s
Reviewed-on: #65
2025-05-26 09:46:21 +00:00
07393cfd61 Merge pull request 'Service Instance Deletion' (#64) from 30-instance-deletion into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #64
2025-05-26 09:45:42 +00:00
4d8d276a9a Merge pull request 'Add and configure django-storages' (#62) from 17-object-storage into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #62
2025-05-26 09:45:17 +00:00
a3186b835f Fix instance creation
All checks were successful
Tests / test (push) Successful in 30s
2025-05-25 23:19:24 +02:00
d32ed1eb55 Fix redirect bug
All checks were successful
Tests / test (push) Successful in 25s
2025-05-25 23:03:05 +02:00
d1d1352cf4 Handle deletion protection 2025-05-25 23:02:42 +02:00
3e466fb011 Instance form and template improvements 2025-05-25 22:56:44 +02:00
52aa6acfb6 Show instance delete modal 2025-05-25 22:54:36 +02:00
3a8b9987b2 Improve instance delete 2025-05-25 22:53:22 +02:00
09ce5406ee Implement service instance delete basics 2025-05-25 22:40:01 +02:00
c570275387 Add and configure django-storages
All checks were successful
Tests / test (push) Successful in 25s
2025-05-21 16:39:31 +02:00
926c9441f2 Merge pull request 'Service Instance Update' (#61) from 29-service-instance-update into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 59s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 8s
Reviewed-on: #61
2025-05-21 07:39:17 +00:00
83b44fd262 Fix missing closing tag
All checks were successful
Tests / test (push) Successful in 24s
2025-05-21 09:31:25 +02:00
4bb52cda4f Add links between detail and update page
All checks were successful
Tests / test (push) Successful in 24s
2025-05-21 09:29:28 +02:00
eb4d3f9556 Implement service instance update 2025-05-21 09:19:31 +02:00
fd3cb6a1d4 Instantiate dynamic model from instance 2025-05-21 09:17:09 +02:00
ba30fb0402 Improve cached data deletion 2025-05-21 09:16:43 +02:00
8a67e16a0b Do not allow the instance’s name to change 2025-05-21 09:16:23 +02:00
032596c0e4 Refactor common view functionality into mixin 2025-05-20 22:39:32 +02:00
f464483c7a Make sure update timestamp is set 2025-05-20 22:37:57 +02:00
51bcd620e1 Improve error on viewing remotely-deleted instance 2025-05-20 22:25:09 +02:00
857105b01f Implement cache invalidation 2025-05-20 22:24:25 +02:00
f65e6e0de0 Implement model updates 2025-05-20 22:24:18 +02:00
9830eebcda Build custom object instantiation 2025-05-20 22:22:17 +02:00
f03940fe61 Merge pull request 'Fix the creation of Forgejo service instances' (#58) from 43-fix-forgejo-instances into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m15s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 9s
Reviewed-on: #58
2025-05-19 14:09:13 +00:00
22178f32fd Merge pull request 'Update Django to 5.2' (#57) from django-52 into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Reviewed-on: #57
2025-05-19 14:08:46 +00:00
4d1215f976 Fix the creation of Forgejo service instances
All checks were successful
Tests / test (push) Successful in 24s
closes #43
2025-05-19 16:04:43 +02:00
e63171f3b5 Update Django to 5.2
All checks were successful
Tests / test (push) Successful in 31s
2025-05-19 15:32:56 +02:00
a1ffdf565d Merge pull request 'Display Instance Details' (#50) from 28-instance-details into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m27s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 8s
Reviewed-on: #50
2025-04-17 15:04:14 +00:00
ff5761ddc7 Code style
All checks were successful
Tests / test (push) Successful in 24s
2025-04-17 17:03:51 +02:00
a5875cf2b9 Fix ControlPlaneAdmin ordering warning
All checks were successful
Tests / test (push) Successful in 24s
2025-04-17 16:58:37 +02:00
aa73805cf6 Use full spec data to retrieve secret ref
All checks were successful
Tests / test (push) Successful in 24s
2025-04-17 15:50:28 +02:00
9ddca7c0a4 Fix secret retrieval condition
All checks were successful
Tests / test (push) Successful in 25s
2025-04-17 15:42:10 +02:00
d2ed55b606 Fix secret retrieval 2025-04-17 15:41:01 +02:00
b2d9004359 Display spec data tabbed like in form
All checks were successful
Tests / test (push) Successful in 26s
2025-04-17 10:47:08 +02:00
6160f48d61 Possibly fix secret retrieval (untested) 2025-04-17 10:20:22 +02:00
c4f7c8df69
document models and admin
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m12s
Build and Deploy Antora Docs / build (push) Successful in 40s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 10s
Build and Deploy Antora Docs / deploy (push) Successful in 5s
2025-04-15 10:36:08 +02:00
fa3eb7c4fc
add missing image to docs and fix url
All checks were successful
Build and Deploy Staging / build (push) Successful in 51s
Build and Deploy Antora Docs / build (push) Successful in 41s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 7s
Build and Deploy Antora Docs / deploy (push) Successful in 6s
2025-04-15 09:22:24 +02:00
c8eaa99d38 Improve card display
All checks were successful
Tests / test (push) Successful in 24s
2025-04-14 10:50:16 +02:00
2a359b50ef Add debugging template filter
All checks were successful
Tests / test (push) Successful in 28s
2025-04-11 17:41:58 +02:00
60b47ed6c8 WIP: connection credentials 2025-04-11 16:55:33 +02:00
40811cbc08 Very rough spec display in instance detail 2025-04-11 16:40:32 +02:00
7afc4400b7 Improve status condition display 2025-04-11 14:00:38 +02:00
93916cdcbc Show conditions in detail view 2025-04-11 13:34:10 +02:00
6d34e3abdc Parse status conditions 2025-04-11 12:47:31 +02:00
912842bd82 Parse out spec data 2025-04-11 10:10:41 +02:00
c4522e31e8 Implement k8s instance retrieval 2025-04-11 09:53:47 +02:00
0eb07feeef Refactor access to group/version/kind 2025-04-11 09:14:58 +02:00
43 changed files with 2472 additions and 672 deletions

View file

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

View file

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

View file

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

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@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docs/Dockerfile file: docs/Dockerfile
@ -52,7 +52,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy to OpenShift - name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.16 uses: docker://quay.io/appuio/oc:v4.18
with: with:
entrypoint: /bin/bash entrypoint: /bin/bash
args: | args: |

View file

@ -0,0 +1,33 @@
name: Renovate Dependency Bot
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Renovate
uses: https://github.com/renovatebot/github-action@v42.0.4
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
LOG_LEVEL: info
RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }}
RENOVATE_PLATFORM: gitea
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate@servala.app.codey.ch>"
RENOVATE_USERNAME: renovate
RENOVATE_ENABLE_PYTHON_TOOL_VERSIONS: true

View file

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

View file

@ -1 +1 @@
3.12 3.13

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -0,0 +1,469 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="981px" height="521px" viewBox="-0.5 -0.5 981 521" content="&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>

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -0,0 +1,226 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="741px" height="211px" viewBox="-0.5 -0.5 741 211" content="&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>

After

Width:  |  Height:  |  Size: 19 KiB

View file

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

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)^] - xref:how-tos/crossplane_service_broker/overview.adoc[Crossplane Service Broker (Docs)] * https://github.com/vshn/crossplane-service-broker[Crossplane Service Broker (Code)^] - https://kb.vshn.ch/app-catalog/csp/spks/crossplane/overview.html[Crossplane Service Broker (Docs)^]
* https://github.com/vshn/swisscom-service-broker[Swisscom Service Broker^] * https://github.com/vshn/swisscom-service-broker[Swisscom Service Broker^]
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^] * https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^]
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^] * https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^]

View file

@ -0,0 +1,59 @@
= Web Portal Admin
The administration of the web portal happens with Django Admin.
[TIP]
====
* Production: https://portal.servala.com/admin[portal.servala.com^]
* Staging: https://staging.portal.servala.com/admin[staging.portal.servala.com^]
====
== Service Catalog and Control-Plane Models
image::portal-service-relations.drawio.svg[]
Service::
The software service, top-level, categorized. +
_Examples_: PostgreSQL, Redis, GitLab. +
Admin: https://staging.portal.servala.com/admin/core/service/[staging^], https://portal.servala.com/admin/core/service/[prod^]
Service Definition::
A correlation between a specific managed service offering with the API definition on the control-planes. It tells the Portal which Kubernetes API implements a managed service. +
_Example_: "Forgejo by VSHN" is implemented by GVK `vshn.appcat.vshn.io/v1/VSHNForgejo` on the control-planes. +
Admin: https://staging.portal.servala.com/admin/core/servicedefinition/[staging^], https://portal.servala.com/admin/core/servicedefinition/[prod^]
Service Offering::
The service offering is the glue which connects a service with a service provider, the control-planes with the service definitions and plan information. It essentially tells the Portal which managed service is available on which control-plane with which specific configuration. It relates to "ControlPlane CRD" which is a correlation between "Service Offering", "Control Plane" and "Service Definition".
_Example_: "Forgejo at Hetzner Cloud" which makes the Service "Forgejo" available at Hetzner Cloud and through "ControlPlane CRDs" it defines which service definition is available in which control-plane at Hetzner Cloud. It also specifies plans with features, pricing and terms. +
Admin: https://staging.portal.servala.com/admin/core/serviceoffering/[staging^], https://portal.servala.com/admin/core/serviceoffering/[prod^]
== Models
In addition to the models described in <<Service Catalog and Control-Plane Models>>, the following core models exist:
Cloud Providers::
Cloud providers where service instances can be provisioned at.
Control Planes::
Connections to Kubernetes API servers. Each control-plane represents a zone at a cloud provider.
Organizations::
The main multi-tenant object.
Organization Memberships::
Defines organization memberships including the roles in an organization.
Organization Origins::
The origin of an organization. Where the organization is coming from, influences e.g. access to control-planes or service offerings.
Billing Entities::
Billing contacts for Organizations - this is not further implemented yet.
Plans::
Plans for service offerings.
Service Categories::
Allows to categorize services.
Service Instances::
Service instances provisioned on control-planes.

View file

@ -0,0 +1,30 @@
= Web Portal Control-Planes
Each control-plane represents a zone at a cloud provider. It's a dedicated Kubernetes API endpoint running the Servala control-plane.
To register a control-plane, a service account with appropriate permissions is required on the Kubernetes API server.
Example:
[source,bash]
----
# Create service account
kubectl -n kube-system create sa servala-portal
# Create long-lived token for service account
kubectl -n kube-system apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: servala-portal-token
annotations:
kubernetes.io/service-account.name: servala-portal
type: kubernetes.io/service-account-token
EOF
# Grant access
kubectl create clusterrolebinding servala-portal-admin --clusterrole=cluster-admin --serviceaccount=kube-system:servala-portal
# Retrieve token
kubectl ksd -n kube-system get secret servala-portal-token -o yaml
----

View file

@ -0,0 +1,64 @@
= Web Portal Planning
image::web-portal-arch.drawio.svg[]
The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala.
Servala is the brand for the product formerly known as "VSHN Application Marketplace".
It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way.
The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view.
External resources to read about it:
* http://vshn.ch/marketplace[vshn.ch Website^]
* https://products.vshn.ch/marketplace/index.html[VSHN products site^]
The source code can be found on the https://servala.app.codey.ch/servala/servala-portal[Servala Codey instance^].
== Technology Stack
We choose:
* Python https://docs.djangoproject.com/en/dev/internals/release-process/#term-Long-term-support-release[Django LTS^]
* PostgreSQL as database backend
* https://gunicorn.org/[Gunicorn^] Python WSGI HTTP Server
* https://caddyserver.com/[Caddy^] for serving static files and WSGI, or https://whitenoise.readthedocs.io/en/latest/[WhiteNoise^] (TBD)
* https://docs.astral.sh/uv/[Astral uv^] for Python project, dependency and build management
* https://htmx.org/[htmx] for the dynamic part in the frontend
* https://getbootstrap.com/[Bootstrap 5] for styling the frontend
A complete reasoning for this stack is available in https://vshnwiki.atlassian.net/wiki/spaces/VSHNPM/pages/402718747/Self-Service+Marketplace+Web+Application[our wiki^] (internal page).
== Development Paradigms
Keep usage of third-party dependencies low::
Every external dependency adds a burden on the maintenance of the application.
Adding a dependency must be done with care:
* Is the dependency well maintained and adopted in the ecosystem?
* Could we do it without the dependency? If not, why?
* What happens if the dependency is abandoned?
* Document the reason for each dependency, why it has been chosen and why we can't live without it.
Graceful degradation::
The application will connect to various upstream APIs which we can't control.
Should issues arise with one of these APIs, gracefully degrade the feature set and inform accordingly ("This service is currently not available - we're working on it").
Never must the application crash because and upstream API not being reachable, has slow response time or react in an undefined way.
Use database and caches wisely::
External systems like databases, caches or queues add additional complexity and burden to the application operations.
Every additional system must be chosen carefully and the reasoning documented.
Alternatives must be considered and documented.
Business logic vs. views::
Whenever possible, split business logic from views.
This allows to progress the application in the future to allow for different views (For example APIs or other alternative frontends).
Django specifics::
* We use class based views by default, exceptions can be made
* Dynamic configuration happens via environment variables
* Different environments (dev / test / prod) must be clearly separated inside the application
Testing::
Business functionality must be https://docs.djangoproject.com/en/5.1/topics/testing/[tested^] with code.
We preferrably use https://docs.pytest.org/[pytest^].

View file

@ -1,64 +1,17 @@
= Web Portal = Web Portal
image::web-portal-arch.drawio.svg[] image::web-portal-arch-current.drawio.svg[]
[TIP]
====
* Production: https://portal.servala.com/[portal.servala.com^]
* Staging: https://staging.portal.servala.com/[staging.portal.servala.com^]
====
The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala. The Servala Web Portal is the central multi-tenant multi-service-provider aggregation and self-service main entrypoint for Servala.
Servala is the brand for the product formerly known as "VSHN Application Marketplace".
It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way. It offers self-service provisioning, multi-tenancy via organizations, access control, and provides central management access over multi cloud providers and the instances running this way.
The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view. The portal is a web application consuming various third party APIs to provide an aggregated and opinionated view.
External resources to read about it: The source code can be found on the https://servala.app.codey.ch/servala/servala-portal[Servala Codey instance^].
* http://vshn.ch/marketplace[vshn.ch Website^]
* https://products.vshn.ch/marketplace/index.html[VSHN products site^]
The source code can be found on the https://servala.app.codey.ch/servala/servala-portal[Servala Codey instance^].
== Technology Stack
We choose:
* Python https://docs.djangoproject.com/en/dev/internals/release-process/#term-Long-term-support-release[Django LTS^]
* PostgreSQL as database backend
* https://gunicorn.org/[Gunicorn^] Python WSGI HTTP Server
* https://caddyserver.com/[Caddy^] for serving static files and WSGI, or https://whitenoise.readthedocs.io/en/latest/[WhiteNoise^] (TBD)
* https://docs.astral.sh/uv/[Astral uv^] for Python project, dependency and build management
* https://htmx.org/[htmx] for the dynamic part in the frontend
* https://getbootstrap.com/[Bootstrap 5] for styling the frontend
A complete reasoning for this stack is available in https://vshnwiki.atlassian.net/wiki/spaces/VSHNPM/pages/402718747/Self-Service+Marketplace+Web+Application[our wiki^] (internal page).
== Development Paradigms
Keep usage of third-party dependencies low::
Every external dependency adds a burden on the maintenance of the application.
Adding a dependency must be done with care:
* Is the dependency well maintained and adopted in the ecosystem?
* Could we do it without the dependency? If not, why?
* What happens if the dependency is abandoned?
* Document the reason for each dependency, why it has been chosen and why we can't live without it.
Graceful degradation::
The application will connect to various upstream APIs which we can't control.
Should issues arise with one of these APIs, gracefully degrade the feature set and inform accordingly ("This service is currently not available - we're working on it").
Never must the application crash because and upstream API not being reachable, has slow response time or react in an undefined way.
Use database and caches wisely::
External systems like databases, caches or queues add additional complexity and burden to the application operations.
Every additional system must be chosen carefully and the reasoning documented.
Alternatives must be considered and documented.
Business logic vs. views::
Whenever possible, split business logic from views.
This allows to progress the application in the future to allow for different views (For example APIs or other alternative frontends).
Django specifics::
* We use class based views by default, exceptions can be made
* Dynamic configuration happens via environment variables
* Different environments (dev / test / prod) must be clearly separated inside the application
Testing::
Business functionality must be https://docs.djangoproject.com/en/5.1/topics/testing/[tested^] with code.
We preferrably use https://docs.pytest.org/[pytest^].

View file

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

43
renovate.json Normal file
View file

@ -0,0 +1,43 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchManagers": [
"github-actions"
],
"matchFileNames": [
".forgejo/workflows/*.yml",
".forgejo/workflows/*.yaml"
]
},
{
"matchManagers": [
"pep621"
],
"rangeStrategy": "bump"
},
{
"matchPackageNames": [
"python"
],
"matchManagers": [
"dockerfile"
],
"versioning": "docker"
}
],
"labels": [
"dependencies"
],
"lockFileMaintenance": {
"enabled": true,
"schedule": [
"before 5am on monday"
]
},
"prConcurrentLimit": 5,
"branchConcurrentLimit": 10
}

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import copy
import json import json
import kubernetes import kubernetes
@ -6,7 +7,8 @@ import urlman
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, models from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField from encrypted_fields.fields import EncryptedJSONField
@ -188,6 +190,10 @@ class ControlPlane(ServalaModelMixin, models.Model):
def get_kubernetes_client(self): def get_kubernetes_client(self):
return kubernetes.client.ApiClient(self.kubernetes_config) return kubernetes.client.ApiClient(self.kubernetes_config)
@cached_property
def custom_objects_api(self):
return client.CustomObjectsApi(self.get_kubernetes_client())
def test_connection(self): def test_connection(self):
if not self.api_credentials: if not self.api_credentials:
return False, _("No API credentials provided") return False, _("No API credentials provided")
@ -355,11 +361,32 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def __str__(self): def __str__(self):
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}" return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
@cached_property
def group(self):
return self.service_definition.api_definition["group"]
@cached_property
def version(self):
return self.service_definition.api_definition["version"]
@cached_property
def kind(self):
return self.service_definition.api_definition["kind"]
@cached_property
def kind_plural(self):
if (
hasattr(self.resource_definition, "status")
and hasattr(self.resource_definition.status, "accepted_names")
and self.resource_definition.status.accepted_names
):
return self.resource_definition.status.accepted_names.plural
if self.kind.endswith("s"):
return self.kind.lower()
return f"{self.kind.lower()}s"
@cached_property @cached_property
def resource_definition(self): def resource_definition(self):
kind = self.service_definition.api_definition["kind"]
group = self.service_definition.api_definition["group"]
version = self.service_definition.api_definition["version"]
client = self.control_plane.get_kubernetes_client() client = self.control_plane.get_kubernetes_client()
extensions_api = kubernetes.client.ApiextensionsV1Api(client) extensions_api = kubernetes.client.ApiextensionsV1Api(client)
@ -368,10 +395,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
for crd in crds.items: for crd in crds.items:
if matching_crd: if matching_crd:
break break
if crd.spec.group == group: if crd.spec.group == self.group:
for served_version in crd.spec.versions: for served_version in crd.spec.versions:
if served_version.name == version and served_version.served: if served_version.name == self.version and served_version.served:
if crd.spec.names.kind == kind: if crd.spec.names.kind == self.kind:
matching_crd = crd matching_crd = crd
break break
return matching_crd return matching_crd
@ -382,9 +409,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
if result := cache.get(cache_key): if result := cache.get(cache_key):
return result return result
version = self.service_definition.api_definition["version"] if not self.resource_definition:
return
for v in self.resource_definition.spec.versions: for v in self.resource_definition.spec.versions:
if v.name == version: if v.name == self.version:
if not v.schema or not v.schema.open_apiv3_schema:
return
result = v.schema.open_apiv3_schema.to_dict() result = v.schema.open_apiv3_schema.to_dict()
timeout_seconds = 60 * 60 * 24 timeout_seconds = 60 * 60 * 24
cache.set(cache_key, result, timeout=timeout_seconds) cache.set(cache_key, result, timeout=timeout_seconds)
@ -394,10 +425,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def django_model(self): def django_model(self):
from servala.core.crd import generate_django_model from servala.core.crd import generate_django_model
if not self.resource_schema:
return
kwargs = { kwargs = {
key: value "group": self.group,
for key, value in self.service_definition.api_definition.items() "version": self.version,
if key in ("group", "version", "kind") "kind": self.kind,
} }
return generate_django_model(self.resource_schema, **kwargs) return generate_django_model(self.resource_schema, **kwargs)
@ -405,6 +439,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def model_form_class(self): def model_form_class(self):
from servala.core.crd import generate_model_form_class from servala.core.crd import generate_model_form_class
if not self.django_model:
return
return generate_model_form_class(self.django_model) return generate_model_form_class(self.django_model)
@ -497,6 +533,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
class urls(urlman.Urls): class urls(urlman.Urls):
base = "{self.organization.urls.instances}{self.name}/" base = "{self.organization.urls.instances}{self.name}/"
update = "{base}update/"
delete = "{base}delete/"
def _clear_kubernetes_caches(self):
"""Clears cached properties that depend on Kubernetes state."""
attrs = self.__dict__.keys()
for key in (
"kubernetes_object",
"spec",
"status_conditions",
"connection_credentials",
):
if key in attrs:
delattr(self, key)
@classmethod @classmethod
def create_instance(cls, name, organization, context, created_by, spec_data): def create_instance(cls, name, organization, context, created_by, spec_data):
@ -517,12 +567,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
) )
try: try:
group = context.service_definition.api_definition["group"]
version = context.service_definition.api_definition["version"]
kind = context.service_definition.api_definition["kind"]
create_data = { create_data = {
"apiVersion": f"{group}/{version}", "apiVersion": f"{context.group}/{context.version}",
"kind": kind, "kind": context.kind,
"metadata": { "metadata": {
"name": name, "name": name,
"namespace": organization.namespace, "namespace": organization.namespace,
@ -531,18 +578,12 @@ class ServiceInstance(ServalaModelMixin, models.Model):
} }
if label := context.control_plane.required_label: if label := context.control_plane.required_label:
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label} create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
api_instance = client.CustomObjectsApi( api_instance = context.control_plane.custom_objects_api
context.control_plane.get_kubernetes_client()
)
plural = kind.lower()
if not plural.endswith("s"):
plural = f"{plural}s"
api_instance.create_namespaced_custom_object( api_instance.create_namespaced_custom_object(
group=group, group=context.group,
version=version, version=context.version,
namespace=organization.namespace, namespace=organization.namespace,
plural=plural, plural=context.kind_plural,
body=create_data, body=create_data,
) )
except Exception as e: except Exception as e:
@ -556,3 +597,194 @@ class ServiceInstance(ServalaModelMixin, models.Model):
raise ValidationError(_("Kubernetes API error: {}").format(str(e))) raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
raise ValidationError(_("Error creating instance: {}").format(str(e))) raise ValidationError(_("Error creating instance: {}").format(str(e)))
return instance return instance
def update_spec(self, spec_data, updated_by):
try:
api_instance = self.context.control_plane.custom_objects_api
patch_body = {"spec": spec_data}
api_instance.patch_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
body=patch_body,
)
self._clear_kubernetes_caches()
self.save() # Updates updated_at timestamp
except ApiException as e:
if e.status == 404:
raise ValidationError(
_(
"Service instance not found in Kubernetes. It may have been deleted externally."
)
)
try:
error_body = json.loads(e.body)
reason = error_body.get("message", str(e))
raise ValidationError(
_("Kubernetes API error updating instance: {error}").format(
error=reason
)
)
except (ValueError, TypeError):
raise ValidationError(
_("Kubernetes API error updating instance: {error}").format(
error=str(e)
)
)
except Exception as e:
raise ValidationError(
_("Error updating instance: {error}").format(error=str(e))
)
@transaction.atomic
def delete_instance(self, user):
"""
Soft deletes the instance in Django and initiates deletion of the
corresponding Kubernetes custom resource.
"""
if self.is_deleted:
return
if (
self.spec.get("parameters", {})
.get("security", {})
.get("deletionProtection")
):
spec = copy.copy(self.spec)
spec["parameters"]["security"]["deletionProtection"] = False
self.update_spec(spec, user)
try:
api_instance = self.context.control_plane.custom_objects_api
api_instance.delete_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
body=client.V1DeleteOptions(),
)
except ApiException as e:
if e.status != 404:
# 404 is fine, the object was deleted already.
raise
self.is_deleted = True
self.deleted_at = timezone.now()
self.deleted_by = user
self.save(
update_fields=["is_deleted", "deleted_at", "deleted_by", "updated_at"]
)
self._clear_kubernetes_caches()
@cached_property
def kubernetes_object(self):
"""Fetch the Kubernetes custom resource object"""
if self.is_deleted:
return
try:
api_instance = client.CustomObjectsApi(
self.context.control_plane.get_kubernetes_client()
)
return api_instance.get_namespaced_custom_object(
group=self.context.group,
version=self.context.version,
namespace=self.organization.namespace,
plural=self.context.kind_plural,
name=self.name,
)
except ApiException as e:
if e.status == 404:
return None
raise
@cached_property
def spec(self):
if not self.kubernetes_object:
return {}
if not (spec := self.kubernetes_object.get("spec")):
return {}
# Remove fields that shouldn't be displayed
spec = spec.copy()
spec.pop("resourceRef", None)
spec.pop("writeConnectionSecretToRef", None)
return spec
@cached_property
def spec_object(self):
"""Dynamically generated CRD object."""
if not self.context.django_model:
return
return self.context.django_model(
name=self.name,
organization=self.organization,
context=self.context,
spec=self.spec,
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
# and b) 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,4 +1,5 @@
from django import forms from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from servala.core.models import ( from servala.core.models import (
@ -6,6 +7,7 @@ from servala.core.models import (
ControlPlane, ControlPlane,
Service, Service,
ServiceCategory, ServiceCategory,
ServiceInstance,
ServiceOffering, ServiceOffering,
) )
@ -17,13 +19,17 @@ class ServiceFilterForm(forms.Form):
cloud_provider = forms.ModelChoiceField( cloud_provider = forms.ModelChoiceField(
queryset=CloudProvider.objects.all(), required=False queryset=CloudProvider.objects.all(), required=False
) )
q = forms.CharField(required=False) q = forms.CharField(label=_("Search"), required=False)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"): if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category) queryset = queryset.filter(category=category)
if cloud_provider := self.cleaned_data.get("cloud_provider"): if cloud_provider := self.cleaned_data.get("cloud_provider"):
queryset = queryset.filter(offerings__provider=cloud_provider) queryset = queryset.filter(offerings__provider=cloud_provider)
if search := self.cleaned_data.get("q"):
queryset = queryset.filter(
Q(name__icontains=search) | Q(category__name__icontains=search)
)
return queryset return queryset
@ -37,6 +43,8 @@ class ControlPlaneSelectForm(forms.Form):
def __init__(self, *args, planes=None, **kwargs): def __init__(self, *args, planes=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["control_plane"].queryset = planes self.fields["control_plane"].queryset = planes
if planes and planes.count() == 1:
self.fields["control_plane"].initial = planes.first()
class ServiceInstanceFilterForm(forms.Form): class ServiceInstanceFilterForm(forms.Form):
@ -68,21 +76,50 @@ class ServiceInstanceFilterForm(forms.Form):
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
if self.is_valid(): if self.is_valid():
data = self.cleaned_data data = self.cleaned_data
if data["name"]: if data.get("name"):
queryset = queryset.filter(name__icontains=data["name"]) queryset = queryset.filter(name__icontains=data["name"])
if data["service"]: if data.get("service"):
queryset = queryset.filter( queryset = queryset.filter(
context__service_definition__service=data["service"] context__service_definition__service=data["service"]
) )
if data["provider"]: if data.get("provider"):
queryset = queryset.filter( queryset = queryset.filter(
context__service_offering__provider=data["provider"] context__service_offering__provider=data["provider"]
) )
if data["control_plane"]: if data.get("control_plane"):
queryset = queryset.filter(context__control_plane=data["control_plane"]) queryset = queryset.filter(context__control_plane=data["control_plane"])
if data["status"]: status = data.get("status")
if data["status"] == "active": if status == "active":
queryset = queryset.filter(is_deleted=False) queryset = queryset.filter(is_deleted=False)
else: elif status == "deleted":
queryset = queryset.filter(is_deleted=True) queryset = queryset.filter(is_deleted=True)
return queryset return queryset
class ServiceInstanceDeleteForm(forms.ModelForm):
name = forms.CharField(
label=_("Instance Name"),
max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}),
)
def __init__(self, *args, **kwargs):
kwargs["initial"] = {"name": ""}
super().__init__(*args, **kwargs)
self.fields["name"].help_text = _(
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>"
).format(instance_name=self.instance.name)
def clean_name(self):
entered_name = self.cleaned_data.get("name")
if entered_name != self.instance.name:
raise forms.ValidationError(
_(
"The entered name does not match the instance name. Deletion not confirmed."
)
)
return entered_name
class Meta:
model = ServiceInstance
fields = ("name",)

View file

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

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" role="alert"> <div class="alert alert-danger form-errors" role="alert">
<div> <div>
{% if form.non_field_errors %} {% if form.non_field_errors %}
{% if form.non_field_errors|length > 1 %} {% if form.non_field_errors|length > 1 %}

View file

@ -28,38 +28,42 @@
</div> </div>
</div> </div>
</div> </div>
{% for offering in service.offerings.all %} <div class="row">
<div class="card col-6 col-lg-3 col-md-4"> {% for offering in service.offerings.all %}
<div class="card-header d-flex align-items-center"> <div class="col-6 col-lg-3 col-md-4">
{% if offering.provider.logo %} <div class="card">
<img src="{{ offering.provider.logo.url }}" <div class="card-header d-flex align-items-center">
alt="{{ offering.provider.name }}" {% if offering.provider.logo %}
class="me-3" <img src="{{ offering.provider.logo.url }}"
style="max-width: 48px; alt="{{ offering.provider.name }}"
max-height: 48px"> class="me-3"
{% endif %} style="max-width: 48px;
<div class="d-flex flex-column"> max-height: 48px">
<h4 class="mb-0">{{ offering.provider.name }}</h4> {% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering.provider.name }}</h4>
</div>
</div>
<div class="card-body">
{% if offering.description %}
<p class="card-text">{{ offering.description }}</p>
{% elif offering.provider.description %}
<p class="card-text">{{ offering.provider.description }}</p>
{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div> </div>
</div> </div>
<div class="card-body"> {% empty %}
{% if offering.description %} <div class="card">
<p class="card-text">{{ offering.description }}</p> <div class="card-body">
{% elif offering.provider.description %} <p>{% translate "No offerings found." %}</p>
<p class="card-text">{{ offering.provider.description }}</p> </div>
{% endif %}
</div> </div>
<div class="card-footer d-flex justify-content-between"> {% endfor %}
<span></span> </div>
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No offerings found." %}</p>
</div>
</div>
{% endfor %}
</section> </section>
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,25 @@
{% load i18n %}
<form method="post"
action="{{ view.request.path }}"
hx-post="{{ view.request.path }}"
hx-target="this"
hx-swap="outerHTML">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="deleteInstanceModalLabel">
{% blocktranslate with instance_name=instance.name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %}
</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body hide-form-errors">
<p>{% translate "Do you really want to delete this service instance? This action cannot be undone." %}</p>
{{ form }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Cancel" %}</button>
<button type="submit" class="btn btn-danger">{% translate "Confirm and Delete Instance" %}</button>
</div>
</form>

View file

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

View file

@ -0,0 +1,43 @@
{% extends "frontend/base.html" %}
{% load i18n %}
{% load static %}
{% load partials %}
{% block html_title %}
{% block page_title %}
{% block title %}
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
{% endblock %}
{% endblock page_title %}
{% endblock html_title %}
{% block page_title_extra %}
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
{% endblock page_title_extra %}
{% partialdef service-form %}
{% if form %}
<div class="card">
<div class="card-header d-flex align-items-center"></div>
<div class="card-body">
{% if form_error %}
<div class="alert alert-danger">
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=form %}
{% endif %}
</div>
</div>
{% endif %}
{% endpartialdef %}
{% block content %}
<section class="section">
<div class="card">
{% if not form %}
<div class="alert alert-warning" role="alert">
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
</div>
{% else %}
<div id="service-form">{% partial service-form %}</div>
{% endif %}
</div>
</section>
{% endblock content %}

View file

@ -16,36 +16,40 @@
</div> </div>
</div> </div>
</div> </div>
{% for service in services %} <div class="row">
<div class="card col-6 col-lg-3 col-md-4"> {% for service in services %}
<div class="card-header d-flex align-items-center"> <div class="col-6 col-lg-3 col-md-4">
{% if service.logo %} <div class="card">
<img src="{{ service.logo.url }}" <div class="card-header d-flex align-items-center">
alt="{{ service.name }}" {% if service.logo %}
class="me-3" <img src="{{ service.logo.url }}"
style="max-width: 48px; alt="{{ service.name }}"
max-height: 48px"> class="me-3"
{% endif %} style="max-width: 48px;
<div class="d-flex flex-column"> max-height: 48px">
<h4 class="mb-0">{{ service.name }}</h4> {% endif %}
<small class="text-muted">{{ service.category }}</small> <div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div> </div>
</div> </div>
<div class="card-body"> {% empty %}
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %} <div class="card">
<div class="card-body">
<p>{% translate "No services found." %}</p>
</div>
</div> </div>
<div class="card-footer d-flex justify-content-between"> {% endfor %}
<span></span> </div>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No services found." %}</p>
</div>
</div>
{% endfor %}
</section> </section>
<script src="{% static "js/autosubmit.js" %}" defer></script> <script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %} {% endblock content %}

View file

@ -6,23 +6,25 @@
{% csrf_token %} {% csrf_token %}
<ul class="nav nav-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %} {% for fieldset in form.get_fieldsets %}
<li class="nav-item" role="presentation"> {% if not fieldset.hidden %}
<button class="nav-link {% if forloop.first %}active{% endif %}" <li class="nav-item" role="presentation">
id="{{ fieldset.title|slugify }}-tab" <button class="nav-link {% if forloop.first %}active{% endif %}"
data-bs-toggle="tab" id="{{ fieldset.title|slugify }}-tab"
data-bs-target="#{{ fieldset.title|slugify }}" data-bs-toggle="tab"
type="button" data-bs-target="#{{ fieldset.title|slugify }}"
role="tab" type="button"
aria-controls="{{ fieldset.title|slugify }}" role="tab"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}"> aria-controls="{{ fieldset.title|slugify }}"
{{ fieldset.title }} aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
</button> {{ fieldset.title }}
</li> </button>
</li>
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="tab-content" id="myTabContent"> <div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %} {% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if forloop.first %}show active{% endif %}" <div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="{{ fieldset.title|slugify }}" id="{{ fieldset.title|slugify }}"
role="tabpanel" role="tabpanel"
aria-labelledby="{{ fieldset.title|slugify }}-tab"> aria-labelledby="{{ fieldset.title|slugify }}-tab">

View file

@ -0,0 +1,12 @@
import json
from django import template
register = template.Library()
@register.filter
def pprint(value):
if isinstance(value, (dict, list)):
return json.dumps(value, indent=2)
return value

View file

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

View file

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

View file

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

View file

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

View file

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

846
uv.lock generated

File diff suppressed because it is too large Load diff