mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-09-08 19:27:18 +02:00
Compare commits
6 Commits
b3dfb8bf22
...
d0cec9a7d4
Author | SHA1 | Date | |
---|---|---|---|
d0cec9a7d4 | |||
1dbd714a56 | |||
3a17b2979e | |||
bb0530c2ac | |||
aa2eb53776 | |||
5f66c1a622 |
@@ -5,6 +5,7 @@ import sys, os
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
from module_utils.config_utils import get_app_conf
|
from module_utils.config_utils import get_app_conf
|
||||||
|
from module_utils.get_url import get_url
|
||||||
|
|
||||||
class FilterModule(object):
|
class FilterModule(object):
|
||||||
"""
|
"""
|
||||||
@@ -110,17 +111,18 @@ class FilterModule(object):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
directives = [
|
directives = [
|
||||||
'default-src',
|
'default-src', # Fallback source list for all content types not explicitly listed
|
||||||
'connect-src',
|
'connect-src', # Controls allowed URLs for XHR, WebSockets, EventSource, and fetch()
|
||||||
'frame-ancestors',
|
'frame-ancestors', # Restricts which parent frames can embed this page via <iframe>, <object>, <embed>, <applet>
|
||||||
'frame-src',
|
'frame-src', # Controls allowed sources for nested browsing contexts like <iframe>
|
||||||
'script-src',
|
'script-src', # Controls allowed sources for inline scripts and <script> elements (general script execution)
|
||||||
'script-src-elem',
|
'script-src-elem', # Controls allowed sources specifically for <script> elements (separate from inline/event handlers)
|
||||||
'style-src',
|
'style-src', # Controls allowed sources for inline styles and <style>/<link> elements (general styles)
|
||||||
'font-src',
|
'style-src-elem', # Controls allowed sources specifically for <style> and <link rel="stylesheet"> elements
|
||||||
'worker-src',
|
'font-src', # Controls allowed sources for fonts loaded via @font-face
|
||||||
'manifest-src',
|
'worker-src', # Controls allowed sources for web workers, shared workers, and service workers
|
||||||
'media-src',
|
'manifest-src', # Controls allowed sources for web app manifests
|
||||||
|
'media-src', # Controls allowed sources for media files like <audio> and <video>
|
||||||
]
|
]
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
@@ -131,18 +133,14 @@ class FilterModule(object):
|
|||||||
flags = self.get_csp_flags(applications, application_id, directive)
|
flags = self.get_csp_flags(applications, application_id, directive)
|
||||||
tokens += flags
|
tokens += flags
|
||||||
|
|
||||||
|
if directive in [ 'script-src-elem', 'connect-src', 'style-src-elem' ]:
|
||||||
|
# Allow fetching from internal CDN as default for all applications
|
||||||
|
tokens.append(get_url(domains,'web-svc-cdn',web_protocol))
|
||||||
|
|
||||||
if directive in ['script-src-elem', 'connect-src']:
|
if directive in ['script-src-elem', 'connect-src']:
|
||||||
# Matomo integration
|
# Matomo integration
|
||||||
if self.is_feature_enabled(applications, matomo_feature_name, application_id):
|
if self.is_feature_enabled(applications, matomo_feature_name, application_id):
|
||||||
matomo_domain = domains.get('web-app-matomo')[0]
|
tokens.append(get_url(domains,'web-app-matomo',web_protocol))
|
||||||
if matomo_domain:
|
|
||||||
tokens.append(f"{web_protocol}://{matomo_domain}")
|
|
||||||
|
|
||||||
# Allow the loading of js from the cdn
|
|
||||||
if self.is_feature_enabled(applications, 'logout', application_id) or self.is_feature_enabled(applications, 'desktop', application_id):
|
|
||||||
domain = domains.get('web-svc-cdn')[0]
|
|
||||||
tokens.append(f"{web_protocol}://{domain}")
|
|
||||||
|
|
||||||
# ReCaptcha integration: allow loading scripts from Google if feature enabled
|
# ReCaptcha integration: allow loading scripts from Google if feature enabled
|
||||||
if self.is_feature_enabled(applications, 'recaptcha', application_id):
|
if self.is_feature_enabled(applications, 'recaptcha', application_id):
|
||||||
@@ -160,12 +158,10 @@ class FilterModule(object):
|
|||||||
if self.is_feature_enabled(applications, 'logout', application_id):
|
if self.is_feature_enabled(applications, 'logout', application_id):
|
||||||
|
|
||||||
# Allow logout via infinito logout proxy
|
# Allow logout via infinito logout proxy
|
||||||
domain = domains.get('web-svc-logout')[0]
|
tokens.append(get_url(domains,'web-svc-logout',web_protocol))
|
||||||
tokens.append(f"{web_protocol}://{domain}")
|
|
||||||
|
|
||||||
# Allow logout via keycloak app
|
# Allow logout via keycloak app
|
||||||
domain = domains.get('web-app-keycloak')[0]
|
tokens.append(get_url(domains,'web-app-keycloak',web_protocol))
|
||||||
tokens.append(f"{web_protocol}://{domain}")
|
|
||||||
|
|
||||||
# whitelist
|
# whitelist
|
||||||
tokens += self.get_csp_whitelist(applications, application_id, directive)
|
tokens += self.get_csp_whitelist(applications, application_id, directive)
|
||||||
|
@@ -0,0 +1,58 @@
|
|||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
def _iter_role_vars_files(roles_dir):
|
||||||
|
if not os.path.isdir(roles_dir):
|
||||||
|
raise AnsibleFilterError(f"roles_dir not found: {roles_dir}")
|
||||||
|
for name in os.listdir(roles_dir):
|
||||||
|
role_path = os.path.join(roles_dir, name)
|
||||||
|
if not os.path.isdir(role_path):
|
||||||
|
continue
|
||||||
|
vars_main = os.path.join(role_path, "vars", "main.yml")
|
||||||
|
if os.path.isfile(vars_main):
|
||||||
|
yield vars_main
|
||||||
|
|
||||||
|
def _is_postgres_role(vars_file):
|
||||||
|
try:
|
||||||
|
with open(vars_file, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
# only count roles with explicit database_type: postgres in VARS
|
||||||
|
return str(data.get("database_type", "")).strip().lower() == "postgres"
|
||||||
|
except Exception:
|
||||||
|
# ignore unreadable/broken YAML files quietly
|
||||||
|
return False
|
||||||
|
|
||||||
|
def split_postgres_connections(total_connections, roles_dir="roles"):
|
||||||
|
"""
|
||||||
|
Return an integer average: total_connections / number_of_roles_with_database_type_postgres.
|
||||||
|
Uses max(count, 1) to avoid division-by-zero.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total = int(total_connections)
|
||||||
|
except Exception:
|
||||||
|
raise AnsibleFilterError(f"total_connections must be int-like, got: {total_connections!r}")
|
||||||
|
|
||||||
|
count = sum(1 for vf in _iter_role_vars_files(roles_dir) if _is_postgres_role(vf))
|
||||||
|
denom = max(count, 1)
|
||||||
|
return max(1, total // denom)
|
||||||
|
|
||||||
|
def list_postgres_roles(roles_dir="roles"):
|
||||||
|
"""
|
||||||
|
Helper: return a list of role names that declare database_type: postgres in vars/main.yml.
|
||||||
|
"""
|
||||||
|
names = []
|
||||||
|
if not os.path.isdir(roles_dir):
|
||||||
|
return names
|
||||||
|
for name in os.listdir(roles_dir):
|
||||||
|
vars_main = os.path.join(roles_dir, name, "vars", "main.yml")
|
||||||
|
if os.path.isfile(vars_main) and _is_postgres_role(vars_main):
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"split_postgres_connections": split_postgres_connections,
|
||||||
|
"list_postgres_roles": list_postgres_roles,
|
||||||
|
}
|
@@ -1,3 +1,7 @@
|
|||||||
|
- name: Compute average allowed connections per Postgres app (once)
|
||||||
|
set_fact:
|
||||||
|
POSTGRES_ALLOWED_AVG_CONNECTIONS: "{{ (POSTGRES_MAX_CONNECTIONS | split_postgres_connections(playbook_dir ~ '/roles')) | int }}"
|
||||||
|
run_once: true
|
||||||
|
|
||||||
- name: Include dependency 'sys-svc-docker'
|
- name: Include dependency 'sys-svc-docker'
|
||||||
include_role:
|
include_role:
|
||||||
|
@@ -7,6 +7,19 @@
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
pull_policy: never
|
pull_policy: never
|
||||||
|
command:
|
||||||
|
- "postgres"
|
||||||
|
- "-c"
|
||||||
|
- "max_connections={{ POSTGRES_MAX_CONNECTIONS }}"
|
||||||
|
- "-c"
|
||||||
|
- "superuser_reserved_connections={{ POSTGRES_SUPERUSER_RESERVED_CONNECTIONS }}"
|
||||||
|
- "-c"
|
||||||
|
- "shared_buffers={{ POSTGRES_SHARED_BUFFERS }}"
|
||||||
|
- "-c"
|
||||||
|
- "work_mem={{ POSTGRES_WORK_MEM }}"
|
||||||
|
- "-c"
|
||||||
|
- "maintenance_work_mem={{ POSTGRES_MAINTENANCE_WORK_MEM }}"
|
||||||
|
|
||||||
{% include 'roles/docker-container/templates/base.yml.j2' %}
|
{% include 'roles/docker-container/templates/base.yml.j2' %}
|
||||||
{% if POSTGRES_EXPOSE_LOCAL %}
|
{% if POSTGRES_EXPOSE_LOCAL %}
|
||||||
ports:
|
ports:
|
||||||
|
@@ -1,25 +1,37 @@
|
|||||||
# General
|
# General
|
||||||
application_id: svc-db-postgres
|
application_id: svc-db-postgres
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker_compose_flush_handlers: true
|
docker_compose_flush_handlers: true
|
||||||
|
|
||||||
# Docker Compose
|
# Docker Compose
|
||||||
database_type: "{{ application_id | get_entity_name }}"
|
database_type: "{{ application_id | get_entity_name }}"
|
||||||
|
|
||||||
## Postgres
|
## Postgres
|
||||||
POSTGRES_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data', True) }}"
|
POSTGRES_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}"
|
||||||
POSTGRES_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.postgres.name', True) }}"
|
POSTGRES_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.postgres.name') }}"
|
||||||
POSTGRES_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.postgres.image', True) }}"
|
POSTGRES_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.postgres.image') }}"
|
||||||
POSTGRES_SUBNET: "{{ networks.local['svc-db-postgres'].subnet }}"
|
POSTGRES_SUBNET: "{{ networks.local['svc-db-postgres'].subnet }}"
|
||||||
POSTGRES_NETWORK_NAME: "{{ applications | get_app_conf(application_id, 'docker.network', True) }}"
|
POSTGRES_NETWORK_NAME: "{{ applications | get_app_conf(application_id, 'docker.network') }}"
|
||||||
POSTGRES_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.postgres.version', True) }}"
|
POSTGRES_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.postgres.version') }}"
|
||||||
POSTGRES_PASSWORD: "{{ applications | get_app_conf(application_id, 'credentials.POSTGRES_PASSWORD', True) }}"
|
POSTGRES_PASSWORD: "{{ applications | get_app_conf(application_id, 'credentials.POSTGRES_PASSWORD') }}"
|
||||||
POSTGRES_PORT: "{{ database_port | default(ports.localhost.database[ application_id ]) }}"
|
POSTGRES_PORT: "{{ database_port | default(ports.localhost.database[ application_id ]) }}"
|
||||||
POSTGRES_INIT: "{{ database_username is defined and database_password is defined and database_name is defined }}"
|
POSTGRES_INIT: "{{ database_username is defined and database_password is defined and database_name is defined }}"
|
||||||
POSTGRES_EXPOSE_LOCAL: True # Exposes the db to localhost, almost everytime neccessary
|
POSTGRES_EXPOSE_LOCAL: True # Exposes the db to localhost, almost everytime neccessary
|
||||||
POSTGRES_CUSTOM_IMAGE_NAME: "postgres_custom"
|
POSTGRES_CUSTOM_IMAGE_NAME: "postgres_custom"
|
||||||
POSTGRES_LOCAL_HOST: "127.0.0.1"
|
POSTGRES_LOCAL_HOST: "127.0.0.1"
|
||||||
POSTGRES_VECTOR_ENABLED: True # Required by discourse, propably in a later step it makes sense to define this as a configuration option in config/main.yml
|
POSTGRES_VECTOR_ENABLED: True # Required by discourse, propably in a later step it makes sense to define this as a configuration option in config/main.yml
|
||||||
POSTGRES_RETRIES: 5
|
POSTGRES_RETRIES: 5
|
||||||
POSTGRES_DELAY: 2
|
|
||||||
|
## Performance
|
||||||
|
POSTGRES_TOTAL_RAM_MB: "{{ ansible_memtotal_mb | int }}"
|
||||||
|
POSTGRES_VCPUS: "{{ ansible_processor_vcpus | int }}"
|
||||||
|
POSTGRES_MAX_CONNECTIONS: "{{ [ ((POSTGRES_VCPUS | int) * 30 + 50), 400 ] | min }}"
|
||||||
|
POSTGRES_SUPERUSER_RESERVED_CONNECTIONS: 3
|
||||||
|
POSTGRES_SHARED_BUFFERS_MB: "{{ ((POSTGRES_TOTAL_RAM_MB | int) * 25) // 100 }}"
|
||||||
|
POSTGRES_SHARED_BUFFERS: "{{ POSTGRES_SHARED_BUFFERS_MB ~ 'MB' }}"
|
||||||
|
POSTGRES_WORK_MEM_MB: "{{ [ ( (POSTGRES_TOTAL_RAM_MB | int) // ( [ (POSTGRES_MAX_CONNECTIONS | int), 1 ] | max ) // 2 ), 1 ] | max }}"
|
||||||
|
POSTGRES_WORK_MEM: "{{ POSTGRES_WORK_MEM_MB ~ 'MB' }}"
|
||||||
|
POSTGRES_MAINTENANCE_WORK_MEM_MB: "{{ [ (((POSTGRES_TOTAL_RAM_MB | int) * 5) // 100), 64 ] | max }}"
|
||||||
|
POSTGRES_MAINTENANCE_WORK_MEM: "{{ POSTGRES_MAINTENANCE_WORK_MEM_MB ~ 'MB' }}"
|
||||||
|
POSTGRES_DELAY: 2
|
||||||
|
@@ -31,11 +31,16 @@ params:
|
|||||||
#version: tests-passed
|
#version: tests-passed
|
||||||
|
|
||||||
env:
|
env:
|
||||||
LC_ALL: en_US.UTF-8
|
LC_ALL: "{{ HOST_LL_CC }}.UTF-8"
|
||||||
LANG: en_US.UTF-8
|
LANG: "{{ HOST_LL_CC }}.UTF-8"
|
||||||
LANGUAGE: en_US.UTF-8
|
LANGUAGE: "{{ HOST_LL_CC }}.UTF-8"
|
||||||
#DISCOURSE_DEFAULT_LOCALE: {{ HOST_LL }} # Deactivated because not right format was selected @todo find right format
|
#DISCOURSE_DEFAULT_LOCALE: {{ HOST_LL }} # Deactivated because not right format was selected @todo find right format
|
||||||
|
|
||||||
|
DB_POOL: "{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
RAILS_MAX_THREADS: "{{ [ (POSTGRES_ALLOWED_AVG_CONNECTIONS | int), 5 ] | min }}"
|
||||||
|
UNICORN_WORKERS: "{{ [ (POSTGRES_ALLOWED_AVG_CONNECTIONS | int) // 2, 1 ] | max }}"
|
||||||
|
|
||||||
|
|
||||||
## How many concurrent web requests are supported? Depends on memory and CPU cores.
|
## How many concurrent web requests are supported? Depends on memory and CPU cores.
|
||||||
## will be set automatically by bootstrap based on detected CPUs, or you can override
|
## will be set automatically by bootstrap based on detected CPUs, or you can override
|
||||||
UNICORN_WORKERS: 8
|
UNICORN_WORKERS: 8
|
||||||
|
@@ -120,3 +120,5 @@ LDAP_USER_ATTR_MAP='{"username":"uid","first_name":"givenName","last_name":"sn",
|
|||||||
FUNKWHALE_FRONTEND_PATH=/srv/funkwhale/front/dist
|
FUNKWHALE_FRONTEND_PATH=/srv/funkwhale/front/dist
|
||||||
|
|
||||||
DATABASE_URL = {{ database_url_full }}
|
DATABASE_URL = {{ database_url_full }}
|
||||||
|
WEB_CONCURRENCY="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
WORKER_THREADS="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS | int // 2 }}"
|
||||||
|
@@ -36,6 +36,7 @@ GITLAB_OMNIBUS_BASE:
|
|||||||
- "gitlab_rails['db_username']='{{ database_username }}'"
|
- "gitlab_rails['db_username']='{{ database_username }}'"
|
||||||
- "gitlab_rails['db_password']='{{ database_password }}'"
|
- "gitlab_rails['db_password']='{{ database_password }}'"
|
||||||
- "gitlab_rails['db_database']='{{ database_name }}'"
|
- "gitlab_rails['db_database']='{{ database_name }}'"
|
||||||
|
- "gitlab_rails['db_pool']={{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
- "nginx['listen_port']=80"
|
- "nginx['listen_port']=80"
|
||||||
- "nginx['listen_https']=false"
|
- "nginx['listen_https']=false"
|
||||||
- ""
|
- ""
|
||||||
|
@@ -40,7 +40,7 @@
|
|||||||
"client_secret": OIDC.CLIENT.SECRET
|
"client_secret": OIDC.CLIENT.SECRET
|
||||||
} | to_json }}
|
} | to_json }}
|
||||||
|
|
||||||
- name: Update administrator email and password login in Listmonk
|
- name: Update administrator email and password login in Listmonk (as superuser)
|
||||||
shell: |
|
shell: |
|
||||||
docker exec -i {{ database_host }} psql \
|
docker exec -i {{ database_host }} psql \
|
||||||
-U {{ database_username }} \
|
-U {{ database_username }} \
|
||||||
|
@@ -16,8 +16,8 @@ password = "{{ database_password }}"
|
|||||||
database = "{{ database_name }}"
|
database = "{{ database_name }}"
|
||||||
|
|
||||||
ssl_mode = "disable"
|
ssl_mode = "disable"
|
||||||
max_open = 25
|
max_open = {{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}
|
||||||
max_idle = 25
|
max_idle = {{ ( POSTGRES_ALLOWED_AVG_CONNECTIONS | int // 2 ) }}
|
||||||
max_lifetime = "300s"
|
max_lifetime = "300s"
|
||||||
|
|
||||||
# Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable"
|
# Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable"
|
||||||
|
@@ -38,6 +38,8 @@ DB_PORT={{ database_port }}
|
|||||||
DB_NAME={{ database_name }}
|
DB_NAME={{ database_name }}
|
||||||
DB_USER={{ database_username }}
|
DB_USER={{ database_username }}
|
||||||
DB_PASS={{ database_password }}
|
DB_PASS={{ database_password }}
|
||||||
|
DB_POOL="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
RAILS_MAX_THREADS="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
@@ -6,7 +6,7 @@ server {
|
|||||||
listen {{ MATRIX_FEDERATION_PORT }} ssl default_server;
|
listen {{ MATRIX_FEDERATION_PORT }} ssl default_server;
|
||||||
listen [::]:{{ MATRIX_FEDERATION_PORT }} ssl default_server;
|
listen [::]:{{ MATRIX_FEDERATION_PORT }} ssl default_server;
|
||||||
|
|
||||||
{% include 'roles/sys-srv-web-inj-compose/templates/server.conf.j2'%}
|
{% include 'roles/sys-front-inj-all/templates/server.conf.j2'%}
|
||||||
|
|
||||||
{% include 'roles/srv-proxy-core/templates/location/html.conf.j2' %}
|
{% include 'roles/srv-proxy-core/templates/location/html.conf.j2' %}
|
||||||
|
|
||||||
|
@@ -17,7 +17,7 @@ database:
|
|||||||
database: "{{ database_name }}"
|
database: "{{ database_name }}"
|
||||||
host: "{{ database_host }}"
|
host: "{{ database_host }}"
|
||||||
cp_min: 5
|
cp_min: 5
|
||||||
cp_max: 10
|
cp_max: {{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}
|
||||||
log_config: "{{ MATRIX_SYNAPSE_LOG_PATH_CONTAINER }}"
|
log_config: "{{ MATRIX_SYNAPSE_LOG_PATH_CONTAINER }}"
|
||||||
media_store_path: "/data/media_store"
|
media_store_path: "/data/media_store"
|
||||||
registration_shared_secret: "{{ MATRIX_REGISTRATION_SHARED_SECRET }}"
|
registration_shared_secret: "{{ MATRIX_REGISTRATION_SHARED_SECRET }}"
|
||||||
|
@@ -60,6 +60,10 @@ MOBILIZON_DATABASE_PORT={{ database_port }}
|
|||||||
# Whether to use SSL to connect to the Mobilizon database. Useful if using an external database.
|
# Whether to use SSL to connect to the Mobilizon database. Useful if using an external database.
|
||||||
# MOBILIZON_DATABASE_SSL=false
|
# MOBILIZON_DATABASE_SSL=false
|
||||||
|
|
||||||
|
# Not sure which of the following variables apply
|
||||||
|
DATABASE_POOL_SIZE="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
POOL_SIZE="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
|
||||||
######################################################
|
######################################################
|
||||||
# Secrets #
|
# Secrets #
|
||||||
######################################################
|
######################################################
|
||||||
|
@@ -6,15 +6,18 @@
|
|||||||
# Please refer to our documentation to see all possible variables:
|
# Please refer to our documentation to see all possible variables:
|
||||||
# https://www.openproject.org/docs/installation-and-operations/configuration/environment/
|
# https://www.openproject.org/docs/installation-and-operations/configuration/environment/
|
||||||
#
|
#
|
||||||
OPENPROJECT_HTTPS=true
|
OPENPROJECT_HTTPS={{ WEB_PORT == 433 | string | lower }}
|
||||||
OPENPROJECT_HOST__NAME={{ domains | get_domain(application_id) }}
|
OPENPROJECT_HOST__NAME={{ domains | get_domain(application_id) }}
|
||||||
OPENPROJECT_RAILS__RELATIVE__URL__ROOT=
|
OPENPROJECT_RAILS__RELATIVE__URL__ROOT=
|
||||||
IMAP_ENABLED=false
|
IMAP_ENABLED=false
|
||||||
POSTGRES_PASSWORD="{{ database_password }}"
|
OPENPROJECT_HSTS={{ WEB_PORT == 433 | string | lower }}
|
||||||
DATABASE_URL="{{ database_url_full }}?pool=20&encoding=unicode&reconnect=true"
|
|
||||||
RAILS_MIN_THREADS=4
|
|
||||||
RAILS_MAX_THREADS=16
|
|
||||||
OPENPROJECT_HSTS=true
|
|
||||||
RAILS_CACHE_STORE: "memcache"
|
RAILS_CACHE_STORE: "memcache"
|
||||||
OPENPROJECT_CACHE__MEMCACHE__SERVER: "cache:11211"
|
OPENPROJECT_CACHE__MEMCACHE__SERVER: "cache:11211"
|
||||||
OPENPROJECT_RAILS__RELATIVE__URL__ROOT: ""
|
OPENPROJECT_RAILS__RELATIVE__URL__ROOT: ""
|
||||||
|
|
||||||
|
# Database
|
||||||
|
POSTGRES_PASSWORD="{{ database_password }}"
|
||||||
|
DATABASE_URL="{{ database_url_full }}?pool=20&encoding=unicode&reconnect=true"
|
||||||
|
DB_POOL="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
RAILS_MAX_THREADS="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
RAILS_MIN_THREADS=4
|
@@ -13,6 +13,8 @@ PRETIX_DATABASE_USER="{{ database_username }}"
|
|||||||
PRETIX_DATABASE_PASSWORD="{{ database_password }}"
|
PRETIX_DATABASE_PASSWORD="{{ database_password }}"
|
||||||
PRETIX_DATABASE_HOST="{{ database_host }}"
|
PRETIX_DATABASE_HOST="{{ database_host }}"
|
||||||
PRETIX_DATABASE_PORT="{{ database_port }}"
|
PRETIX_DATABASE_PORT="{{ database_port }}"
|
||||||
|
PRETIX_WEB_CONCURRENCY="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
|
||||||
|
PRETIX_WORKER_THREADS="{{ (POSTGRES_ALLOWED_AVG_CONNECTIONS | int // 2 ) }}"
|
||||||
|
|
||||||
## Redis
|
## Redis
|
||||||
PRETIX_REDIS_LOCATION="redis://redis:6379/1"
|
PRETIX_REDIS_LOCATION="redis://redis:6379/1"
|
||||||
|
@@ -9,7 +9,7 @@ oauth2_proxy:
|
|||||||
features:
|
features:
|
||||||
matomo: true
|
matomo: true
|
||||||
css: true
|
css: true
|
||||||
desktop: true
|
desktop: true
|
||||||
central_database: true
|
central_database: true
|
||||||
oauth2: true
|
oauth2: true
|
||||||
logout: true
|
logout: true
|
||||||
@@ -34,7 +34,7 @@ server:
|
|||||||
docker:
|
docker:
|
||||||
services:
|
services:
|
||||||
database:
|
database:
|
||||||
enabled: true
|
enabled: true
|
||||||
yourls:
|
yourls:
|
||||||
version: "latest"
|
version: "latest"
|
||||||
name: "yourls"
|
name: "yourls"
|
||||||
|
@@ -1,10 +1,8 @@
|
|||||||
{% include 'roles/docker-compose/templates/base.yml.j2' %}
|
{% include 'roles/docker-compose/templates/base.yml.j2' %}
|
||||||
|
|
||||||
application:
|
application:
|
||||||
{% set container_port = 80 %}
|
image: "{{ YOURLS_IMAGE }}:{{ YOURLS_VERSION }}"
|
||||||
{% set container_healthcheck = 'http://127.0.0.1' ~ yourls_admin_location %}
|
container_name: "{{ YOURLS_CONTAINER }}"
|
||||||
image: "{{ yourls_image }}:{{ yourls_version }}"
|
|
||||||
container_name: "{{ yourls_container }}"
|
|
||||||
{% include 'roles/docker-container/templates/base.yml.j2' %}
|
{% include 'roles/docker-container/templates/base.yml.j2' %}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}"
|
- "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}"
|
||||||
|
@@ -3,7 +3,7 @@ YOURLS_DB_USER: "{{ database_username }}"
|
|||||||
YOURLS_DB_PASS: "{{ database_password }}"
|
YOURLS_DB_PASS: "{{ database_password }}"
|
||||||
YOURLS_DB_NAME: "{{ database_name }}"
|
YOURLS_DB_NAME: "{{ database_name }}"
|
||||||
YOURLS_SITE: "{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
|
YOURLS_SITE: "{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
|
||||||
YOURLS_USER: "{{ yourls_user }}"
|
YOURLS_USER: "{{ YOURLS_USER }}"
|
||||||
YOURLS_PASS: "{{ yourls_password }}"
|
YOURLS_PASS: "{{ YOURLS_PASSWORD }}"
|
||||||
# The following deactivates the login mask for admins, if the oauth2 proxy is activated
|
# The following deactivates the login mask for admins, if the oauth2 proxy is activated
|
||||||
YOURLS_PRIVATE: "{{not (applications | get_app_conf(application_id, 'features.oauth2', False))}}"
|
YOURLS_PRIVATE: "{{not (applications | get_app_conf(application_id, 'features.oauth2', False))}}"
|
@@ -1,3 +1,3 @@
|
|||||||
location = / {
|
location = / {
|
||||||
return {{ yourls_landingpage_status_code }} {{ yourls_admin_location }};
|
return {{ YOURLS_LANDINGPAGE_STATUS_CODE }} {{ YOURLS_ADMIN_LOCATION }};
|
||||||
}
|
}
|
@@ -2,14 +2,18 @@
|
|||||||
application_id: "web-app-yourls"
|
application_id: "web-app-yourls"
|
||||||
database_type: "mariadb"
|
database_type: "mariadb"
|
||||||
|
|
||||||
# Yourls Specific
|
# Webserver
|
||||||
yourls_user: "{{ applications | get_app_conf(application_id, 'users.administrator.username') }}"
|
|
||||||
yourls_password: "{{ applications | get_app_conf(application_id, 'credentials.administrator_password', True) }}"
|
|
||||||
yourls_version: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.version', True) }}"
|
|
||||||
yourls_image: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.image', True) }}"
|
|
||||||
yourls_container: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.name', True) }}"
|
|
||||||
yourls_admin_location: "{{ applications | get_app_conf(application_id, 'server.locations.admin', True) }}"
|
|
||||||
yourls_landingpage_status_code: "{{ applications | get_app_conf(application_id, 'server.status_codes.landingpage', True) }}"
|
|
||||||
|
|
||||||
# Nginx Specific
|
|
||||||
proxy_extra_configuration: "{{ lookup('template', 'redirect.conf.j2') }}"
|
proxy_extra_configuration: "{{ lookup('template', 'redirect.conf.j2') }}"
|
||||||
|
|
||||||
|
# Yourls
|
||||||
|
YOURLS_USER: "{{ applications | get_app_conf(application_id, 'users.administrator.username') }}"
|
||||||
|
YOURLS_PASSWORD: "{{ applications | get_app_conf(application_id, 'credentials.administrator_password') }}"
|
||||||
|
YOURLS_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.version') }}"
|
||||||
|
YOURLS_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.image') }}"
|
||||||
|
YOURLS_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.name') }}"
|
||||||
|
YOURLS_ADMIN_LOCATION: "{{ applications | get_app_conf(application_id, 'server.locations.admin') }}"
|
||||||
|
YOURLS_LANDINGPAGE_STATUS_CODE: "{{ applications | get_app_conf(application_id, 'server.status_codes.landingpage') }}"
|
||||||
|
|
||||||
|
# Container
|
||||||
|
container_port: 8080
|
||||||
|
container_healthcheck: "{{ '' | safe_join(YOURLS_ADMIN_LOCATION) }}/"
|
||||||
|
@@ -45,7 +45,7 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
"body { background: #fff; }",
|
"body { background: #fff; }",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'app2': {}
|
'app2': {}
|
||||||
@@ -55,6 +55,28 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
'web-svc-cdn': ['cdn.example.org'],
|
'web-svc-cdn': ['cdn.example.org'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_directive_tokens(self, header: str, directive: str):
|
||||||
|
"""
|
||||||
|
Extract tokens (as a list of strings) for a given directive from a CSP header.
|
||||||
|
Example: for "connect-src 'self' https://a https://b;" -> ["'self'", "https://a", "https://b"]
|
||||||
|
Returns [] if not found.
|
||||||
|
"""
|
||||||
|
for part in header.split(';'):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if part.startswith(directive + ' '):
|
||||||
|
# remove directive name, split remainder by spaces
|
||||||
|
remainder = part[len(directive):].strip()
|
||||||
|
return [tok for tok in remainder.split(' ') if tok]
|
||||||
|
if part == directive: # unlikely, but guard
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
# --- Tests ---------------------------------------------------------------
|
||||||
|
|
||||||
def test_get_csp_whitelist_list(self):
|
def test_get_csp_whitelist_list(self):
|
||||||
result = self.filter.get_csp_whitelist(self.apps, 'app1', 'script-src-elem')
|
result = self.filter.get_csp_whitelist(self.apps, 'app1', 'script-src-elem')
|
||||||
self.assertEqual(result, ['https://cdn.example.com'])
|
self.assertEqual(result, ['https://cdn.example.com'])
|
||||||
@@ -85,20 +107,23 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
||||||
# Ensure core directives are present
|
# Ensure core directives are present
|
||||||
self.assertIn("default-src 'self';", header)
|
self.assertIn("default-src 'self';", header)
|
||||||
# script-src directive should include unsafe-eval, Matomo domain and CDN (hash may follow)
|
|
||||||
self.assertIn(
|
# script-src-elem should include 'self', Matomo, internes CDN und explizite Whitelist-CDN
|
||||||
"script-src-elem 'self' https://matomo.example.org https://cdn.example.com",
|
self.assertIn("script-src-elem 'self'", header)
|
||||||
header
|
self.assertIn("https://matomo.example.org", header)
|
||||||
)
|
self.assertIn("https://cdn.example.org", header) # internes CDN
|
||||||
self.assertIn(
|
self.assertIn("https://cdn.example.com", header) # Whitelist
|
||||||
"script-src 'self' 'unsafe-eval'",
|
|
||||||
header
|
# script-src directive should include unsafe-eval
|
||||||
)
|
self.assertIn("script-src 'self' 'unsafe-eval'", header)
|
||||||
# connect-src directive unchanged (no inline hash)
|
|
||||||
self.assertIn(
|
# connect-src directive (reihenfolgeunabhängig prüfen)
|
||||||
"connect-src 'self' https://matomo.example.org https://api.example.com;",
|
tokens = self._get_directive_tokens(header, "connect-src")
|
||||||
header
|
self.assertIn("'self'", tokens)
|
||||||
)
|
self.assertIn("https://matomo.example.org", tokens)
|
||||||
|
self.assertIn("https://cdn.example.org", tokens)
|
||||||
|
self.assertIn("https://api.example.com", tokens)
|
||||||
|
|
||||||
# ends with img-src
|
# ends with img-src
|
||||||
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
|
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
|
||||||
|
|
||||||
@@ -106,8 +131,9 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
header = self.filter.build_csp_header(self.apps, 'app2', self.domains)
|
header = self.filter.build_csp_header(self.apps, 'app2', self.domains)
|
||||||
# default-src only contains 'self'
|
# default-src only contains 'self'
|
||||||
self.assertIn("default-src 'self';", header)
|
self.assertIn("default-src 'self';", header)
|
||||||
# no external URLs
|
self.assertIn('https://cdn.example.org', header)
|
||||||
self.assertNotIn('http', header)
|
self.assertNotIn('matomo.example.org', header)
|
||||||
|
self.assertNotIn('www.google.com', header)
|
||||||
# ends with img-src
|
# ends with img-src
|
||||||
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
|
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
|
||||||
|
|
||||||
@@ -154,7 +180,6 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
style_hash = self.filter.get_csp_hash("body { background: #fff; }")
|
style_hash = self.filter.get_csp_hash("body { background: #fff; }")
|
||||||
self.assertNotIn(style_hash, header)
|
self.assertNotIn(style_hash, header)
|
||||||
|
|
||||||
|
|
||||||
def test_build_csp_header_recaptcha_toggle(self):
|
def test_build_csp_header_recaptcha_toggle(self):
|
||||||
"""
|
"""
|
||||||
When the 'recaptcha' feature is enabled, 'https://www.google.com'
|
When the 'recaptcha' feature is enabled, 'https://www.google.com'
|
||||||
@@ -185,7 +210,7 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
self.domains['web-app-desktop'] = ['domain-example.com']
|
self.domains['web-app-desktop'] = ['domain-example.com']
|
||||||
|
|
||||||
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
||||||
# Expect '*.domain-example.com' in the frame-ancestors directive
|
# Expect 'domain-example.com' in the frame-ancestors directive
|
||||||
self.assertRegex(
|
self.assertRegex(
|
||||||
header,
|
header,
|
||||||
r"frame-ancestors\s+'self'\s+domain-example\.com;"
|
r"frame-ancestors\s+'self'\s+domain-example\.com;"
|
||||||
@@ -194,8 +219,8 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
# Now disable the feature and rebuild
|
# Now disable the feature and rebuild
|
||||||
self.apps['app1']['features']['desktop'] = False
|
self.apps['app1']['features']['desktop'] = False
|
||||||
header_no = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
header_no = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
||||||
# Should no longer contain the wildcarded sld.tld
|
# Should no longer contain the SLD+TLD
|
||||||
self.assertNotIn("*.domain-example.com", header_no)
|
self.assertNotIn("domain-example.com", header_no)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
0
tests/unit/roles/svc-db-postgres/__init__.py
Normal file
0
tests/unit/roles/svc-db-postgres/__init__.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from types import ModuleType
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
|
||||||
|
def load_filter_module(repo_root: str) -> ModuleType:
|
||||||
|
"""
|
||||||
|
Load the filter plugin from:
|
||||||
|
roles/svc-db-postgres/filter_plugins/split_postgres_connections.py
|
||||||
|
"""
|
||||||
|
plugin_path = os.path.join(
|
||||||
|
repo_root, "roles", "svc-db-postgres", "filter_plugins", "split_postgres_connections.py"
|
||||||
|
)
|
||||||
|
if not os.path.isfile(plugin_path):
|
||||||
|
raise FileNotFoundError(f"Filter plugin not found at {plugin_path}")
|
||||||
|
spec = importlib.util.spec_from_file_location("split_postgres_connections_plugin", plugin_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def write_role_vars(repo_root: str, role_name: str, database_type: str | None):
|
||||||
|
"""
|
||||||
|
Create a minimal role with optional vars/main.yml containing database_type.
|
||||||
|
"""
|
||||||
|
role_dir = os.path.join(repo_root, "roles", role_name)
|
||||||
|
vars_dir = os.path.join(role_dir, "vars")
|
||||||
|
os.makedirs(role_dir, exist_ok=True)
|
||||||
|
if database_type is not None:
|
||||||
|
os.makedirs(vars_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(vars_dir, "main.yml"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(textwrap.dedent(f"""\
|
||||||
|
# auto-generated for test
|
||||||
|
database_type: {database_type}
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
class SplitPostgresConnectionsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Create an isolated temporary repository layout
|
||||||
|
self.repo = tempfile.mkdtemp(prefix="repo_")
|
||||||
|
self.roles_dir = os.path.join(self.repo, "roles")
|
||||||
|
os.makedirs(self.roles_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Create roles:
|
||||||
|
# - app_a (postgres)
|
||||||
|
# - app_b (postgres)
|
||||||
|
# - app_c (mysql)
|
||||||
|
# - app_d (no vars/main.yml)
|
||||||
|
write_role_vars(self.repo, "app_a", "postgres")
|
||||||
|
write_role_vars(self.repo, "app_b", "postgres")
|
||||||
|
write_role_vars(self.repo, "app_c", "mysql")
|
||||||
|
write_role_vars(self.repo, "app_d", None)
|
||||||
|
|
||||||
|
# Copy the real plugin into this temp repo structure, preserving your path layout.
|
||||||
|
# (Adjust src_plugin_path if your test runner runs from a different CWD.)
|
||||||
|
src_plugin_path = os.path.join(
|
||||||
|
os.getcwd(), "roles", "svc-db-postgres", "filter_plugins", "split_postgres_connections.py"
|
||||||
|
)
|
||||||
|
if not os.path.isfile(src_plugin_path):
|
||||||
|
self.skipTest(f"Source plugin not found at {src_plugin_path}")
|
||||||
|
dst_plugin_dir = os.path.join(self.repo, "roles", "svc-db-postgres", "filter_plugins")
|
||||||
|
os.makedirs(dst_plugin_dir, exist_ok=True)
|
||||||
|
shutil.copy2(src_plugin_path, os.path.join(dst_plugin_dir, "split_postgres_connections.py"))
|
||||||
|
|
||||||
|
self.mod = load_filter_module(self.repo)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.repo, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_registry_contains_filters(self):
|
||||||
|
registry = self.mod.FilterModule().filters()
|
||||||
|
self.assertIn("split_postgres_connections", registry)
|
||||||
|
self.assertIn("list_postgres_roles", registry)
|
||||||
|
|
||||||
|
def test_list_postgres_roles(self):
|
||||||
|
roles = self.mod.list_postgres_roles(self.roles_dir)
|
||||||
|
self.assertIsInstance(roles, list)
|
||||||
|
self.assertSetEqual(set(roles), {"app_a", "app_b"})
|
||||||
|
|
||||||
|
def test_split_postgres_connections_division(self):
|
||||||
|
# There are 2 postgres roles -> 200 / 2 = 100
|
||||||
|
avg = self.mod.split_postgres_connections(200, roles_dir=self.roles_dir)
|
||||||
|
self.assertEqual(avg, 100)
|
||||||
|
|
||||||
|
# 5 / 2 -> floor 2
|
||||||
|
self.assertEqual(self.mod.split_postgres_connections(5, roles_dir=self.roles_dir), 2)
|
||||||
|
|
||||||
|
# Safety floor: at least 1
|
||||||
|
self.assertEqual(self.mod.split_postgres_connections(1, roles_dir=self.roles_dir), 1)
|
||||||
|
|
||||||
|
def test_split_handles_non_int_input(self):
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.mod.split_postgres_connections("not-an-int", roles_dir=self.roles_dir)
|
||||||
|
|
||||||
|
def test_missing_roles_dir_raises(self):
|
||||||
|
# Current plugin behavior: raise if roles_dir does not exist
|
||||||
|
with self.assertRaises(AnsibleFilterError):
|
||||||
|
self.mod.split_postgres_connections(100, roles_dir="/does/not/exist")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Reference in New Issue
Block a user