mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-04 12:18:17 +00: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__), '..')))
 | 
			
		||||
from module_utils.config_utils import get_app_conf
 | 
			
		||||
from module_utils.get_url import get_url
 | 
			
		||||
 | 
			
		||||
class FilterModule(object):
 | 
			
		||||
    """
 | 
			
		||||
@@ -110,17 +111,18 @@ class FilterModule(object):
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            directives = [
 | 
			
		||||
                'default-src',
 | 
			
		||||
                'connect-src',
 | 
			
		||||
                'frame-ancestors',
 | 
			
		||||
                'frame-src',
 | 
			
		||||
                'script-src',
 | 
			
		||||
                'script-src-elem',
 | 
			
		||||
                'style-src',
 | 
			
		||||
                'font-src',
 | 
			
		||||
                'worker-src',
 | 
			
		||||
                'manifest-src',
 | 
			
		||||
                'media-src',
 | 
			
		||||
                'default-src',      # Fallback source list for all content types not explicitly listed
 | 
			
		||||
                'connect-src',      # Controls allowed URLs for XHR, WebSockets, EventSource, and fetch()
 | 
			
		||||
                'frame-ancestors',  # Restricts which parent frames can embed this page via <iframe>, <object>, <embed>, <applet>
 | 
			
		||||
                'frame-src',        # Controls allowed sources for nested browsing contexts like <iframe>
 | 
			
		||||
                'script-src',       # Controls allowed sources for inline scripts and <script> elements (general script execution)
 | 
			
		||||
                'script-src-elem',  # Controls allowed sources specifically for <script> elements (separate from inline/event handlers)
 | 
			
		||||
                'style-src',        # Controls allowed sources for inline styles and <style>/<link> elements (general styles)
 | 
			
		||||
                'style-src-elem',   # Controls allowed sources specifically for <style> and <link rel="stylesheet"> elements
 | 
			
		||||
                'font-src',         # Controls allowed sources for fonts loaded via @font-face
 | 
			
		||||
                'worker-src',       # Controls allowed sources for web workers, shared workers, and service workers
 | 
			
		||||
                'manifest-src',     # Controls allowed sources for web app manifests
 | 
			
		||||
                'media-src',        # Controls allowed sources for media files like <audio> and <video>
 | 
			
		||||
            ]
 | 
			
		||||
            parts = []
 | 
			
		||||
 | 
			
		||||
@@ -131,18 +133,14 @@ class FilterModule(object):
 | 
			
		||||
                flags = self.get_csp_flags(applications, application_id, directive)
 | 
			
		||||
                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']:
 | 
			
		||||
                    # Matomo integration
 | 
			
		||||
                    if self.is_feature_enabled(applications, matomo_feature_name, application_id):
 | 
			
		||||
                        matomo_domain = domains.get('web-app-matomo')[0]
 | 
			
		||||
                        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}")
 | 
			
		||||
                        tokens.append(get_url(domains,'web-app-matomo',web_protocol))
 | 
			
		||||
 | 
			
		||||
                # ReCaptcha integration: allow loading scripts from Google if feature enabled
 | 
			
		||||
                if self.is_feature_enabled(applications, 'recaptcha', application_id):
 | 
			
		||||
@@ -160,13 +158,11 @@ class FilterModule(object):
 | 
			
		||||
                    if self.is_feature_enabled(applications, 'logout', application_id):
 | 
			
		||||
                        
 | 
			
		||||
                        # Allow logout via infinito logout proxy
 | 
			
		||||
                        domain = domains.get('web-svc-logout')[0] 
 | 
			
		||||
                        tokens.append(f"{web_protocol}://{domain}")
 | 
			
		||||
                        tokens.append(get_url(domains,'web-svc-logout',web_protocol))
 | 
			
		||||
                        
 | 
			
		||||
                        # Allow logout via keycloak app
 | 
			
		||||
                        domain = domains.get('web-app-keycloak')[0]
 | 
			
		||||
                        tokens.append(f"{web_protocol}://{domain}")
 | 
			
		||||
                        
 | 
			
		||||
                        tokens.append(get_url(domains,'web-app-keycloak',web_protocol))
 | 
			
		||||
 | 
			
		||||
                # whitelist
 | 
			
		||||
                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'
 | 
			
		||||
  include_role:
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,19 @@
 | 
			
		||||
      context: .
 | 
			
		||||
      dockerfile: Dockerfile
 | 
			
		||||
    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' %}
 | 
			
		||||
{% if POSTGRES_EXPOSE_LOCAL %}
 | 
			
		||||
    ports: 
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +1,37 @@
 | 
			
		||||
# General
 | 
			
		||||
application_id:                 svc-db-postgres
 | 
			
		||||
application_id:                           svc-db-postgres
 | 
			
		||||
 | 
			
		||||
# Docker
 | 
			
		||||
docker_compose_flush_handlers:  true
 | 
			
		||||
docker_compose_flush_handlers:            true
 | 
			
		||||
 | 
			
		||||
# Docker Compose
 | 
			
		||||
database_type:                  "{{ application_id | get_entity_name }}"
 | 
			
		||||
database_type:                            "{{ application_id | get_entity_name }}"
 | 
			
		||||
 | 
			
		||||
## Postgres
 | 
			
		||||
POSTGRES_VOLUME:                "{{ applications | get_app_conf(application_id, 'docker.volumes.data', True) }}"
 | 
			
		||||
POSTGRES_CONTAINER:             "{{ applications | get_app_conf(application_id, 'docker.services.postgres.name', True) }}"
 | 
			
		||||
POSTGRES_IMAGE:                 "{{ applications | get_app_conf(application_id, 'docker.services.postgres.image', True) }}"
 | 
			
		||||
POSTGRES_SUBNET:                "{{ networks.local['svc-db-postgres'].subnet }}"
 | 
			
		||||
POSTGRES_NETWORK_NAME:          "{{ applications | get_app_conf(application_id, 'docker.network', True) }}"
 | 
			
		||||
POSTGRES_VERSION:               "{{ applications | get_app_conf(application_id, 'docker.services.postgres.version', True) }}"
 | 
			
		||||
POSTGRES_PASSWORD:              "{{ applications | get_app_conf(application_id, 'credentials.POSTGRES_PASSWORD', True) }}"
 | 
			
		||||
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_EXPOSE_LOCAL:          True # Exposes the db to localhost, almost everytime neccessary
 | 
			
		||||
POSTGRES_CUSTOM_IMAGE_NAME:     "postgres_custom"
 | 
			
		||||
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_RETRIES:               5
 | 
			
		||||
POSTGRES_DELAY:                 2
 | 
			
		||||
POSTGRES_VOLUME:                          "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}"
 | 
			
		||||
POSTGRES_CONTAINER:                       "{{ applications | get_app_conf(application_id, 'docker.services.postgres.name') }}"
 | 
			
		||||
POSTGRES_IMAGE:                           "{{ applications | get_app_conf(application_id, 'docker.services.postgres.image') }}"
 | 
			
		||||
POSTGRES_SUBNET:                          "{{ networks.local['svc-db-postgres'].subnet }}"
 | 
			
		||||
POSTGRES_NETWORK_NAME:                    "{{ applications | get_app_conf(application_id, 'docker.network') }}"
 | 
			
		||||
POSTGRES_VERSION:                         "{{ applications | get_app_conf(application_id, 'docker.services.postgres.version') }}"
 | 
			
		||||
POSTGRES_PASSWORD:                        "{{ applications | get_app_conf(application_id, 'credentials.POSTGRES_PASSWORD') }}"
 | 
			
		||||
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_EXPOSE_LOCAL:                    True # Exposes the db to localhost, almost everytime neccessary
 | 
			
		||||
POSTGRES_CUSTOM_IMAGE_NAME:               "postgres_custom"
 | 
			
		||||
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_RETRIES:                         5
 | 
			
		||||
 | 
			
		||||
## 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
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  LC_ALL: en_US.UTF-8
 | 
			
		||||
  LANG: en_US.UTF-8
 | 
			
		||||
  LANGUAGE: en_US.UTF-8
 | 
			
		||||
  LC_ALL:   "{{ HOST_LL_CC }}.UTF-8"
 | 
			
		||||
  LANG:     "{{ HOST_LL_CC }}.UTF-8"
 | 
			
		||||
  LANGUAGE: "{{ HOST_LL_CC }}.UTF-8"
 | 
			
		||||
  #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.
 | 
			
		||||
  ## will be set automatically by bootstrap based on detected CPUs, or you can override
 | 
			
		||||
  UNICORN_WORKERS: 8
 | 
			
		||||
 
 | 
			
		||||
@@ -119,4 +119,6 @@ LDAP_USER_ATTR_MAP='{"username":"uid","first_name":"givenName","last_name":"sn",
 | 
			
		||||
 | 
			
		||||
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_password']='{{ database_password }}'"
 | 
			
		||||
  - "gitlab_rails['db_database']='{{ database_name }}'"
 | 
			
		||||
  - "gitlab_rails['db_pool']={{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
 | 
			
		||||
  - "nginx['listen_port']=80"
 | 
			
		||||
  - "nginx['listen_https']=false"
 | 
			
		||||
  - ""
 | 
			
		||||
 
 | 
			
		||||
@@ -40,7 +40,7 @@
 | 
			
		||||
         "client_secret": OIDC.CLIENT.SECRET
 | 
			
		||||
      } | to_json }}
 | 
			
		||||
 | 
			
		||||
- name: Update administrator email and password login in Listmonk
 | 
			
		||||
- name: Update administrator email and password login in Listmonk (as superuser)
 | 
			
		||||
  shell: |
 | 
			
		||||
    docker exec -i {{ database_host }} psql \
 | 
			
		||||
      -U {{ database_username }} \
 | 
			
		||||
 
 | 
			
		||||
@@ -16,8 +16,8 @@ password = "{{ database_password }}"
 | 
			
		||||
database = "{{ database_name }}"
 | 
			
		||||
 | 
			
		||||
ssl_mode = "disable"
 | 
			
		||||
max_open = 25
 | 
			
		||||
max_idle = 25
 | 
			
		||||
max_open = {{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}
 | 
			
		||||
max_idle = {{ ( POSTGRES_ALLOWED_AVG_CONNECTIONS | int // 2 ) }}
 | 
			
		||||
max_lifetime = "300s"
 | 
			
		||||
 | 
			
		||||
# 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_USER={{ database_username }}
 | 
			
		||||
DB_PASS={{ database_password }}
 | 
			
		||||
DB_POOL="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
 | 
			
		||||
RAILS_MAX_THREADS="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
 | 
			
		||||
 | 
			
		||||
REDIS_HOST=redis
 | 
			
		||||
REDIS_PORT=6379
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ 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' %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@ database:
 | 
			
		||||
    database: "{{ database_name }}"
 | 
			
		||||
    host: "{{ database_host }}"
 | 
			
		||||
    cp_min: 5
 | 
			
		||||
    cp_max: 10
 | 
			
		||||
    cp_max: {{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}
 | 
			
		||||
log_config:                     "{{ MATRIX_SYNAPSE_LOG_PATH_CONTAINER }}"
 | 
			
		||||
media_store_path:               "/data/media_store"
 | 
			
		||||
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.
 | 
			
		||||
# 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                                            #
 | 
			
		||||
######################################################
 | 
			
		||||
 
 | 
			
		||||
@@ -6,15 +6,18 @@
 | 
			
		||||
# Please refer to our documentation to see all possible variables:
 | 
			
		||||
#   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_RAILS__RELATIVE__URL__ROOT=
 | 
			
		||||
IMAP_ENABLED=false
 | 
			
		||||
POSTGRES_PASSWORD="{{ database_password }}"
 | 
			
		||||
DATABASE_URL="{{ database_url_full }}?pool=20&encoding=unicode&reconnect=true"
 | 
			
		||||
RAILS_MIN_THREADS=4
 | 
			
		||||
RAILS_MAX_THREADS=16
 | 
			
		||||
OPENPROJECT_HSTS=true
 | 
			
		||||
OPENPROJECT_HSTS={{ WEB_PORT == 433 | string | lower }}
 | 
			
		||||
RAILS_CACHE_STORE: "memcache"
 | 
			
		||||
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_HOST="{{ database_host }}"
 | 
			
		||||
PRETIX_DATABASE_PORT="{{ database_port }}"
 | 
			
		||||
PRETIX_WEB_CONCURRENCY="{{ POSTGRES_ALLOWED_AVG_CONNECTIONS }}"
 | 
			
		||||
PRETIX_WORKER_THREADS="{{ (POSTGRES_ALLOWED_AVG_CONNECTIONS | int // 2 ) }}"
 | 
			
		||||
 | 
			
		||||
## Redis
 | 
			
		||||
PRETIX_REDIS_LOCATION="redis://redis:6379/1"
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ oauth2_proxy:
 | 
			
		||||
features:
 | 
			
		||||
  matomo:             true
 | 
			
		||||
  css:                true
 | 
			
		||||
  desktop:    true
 | 
			
		||||
  desktop:            true
 | 
			
		||||
  central_database:   true
 | 
			
		||||
  oauth2:             true
 | 
			
		||||
  logout:             true
 | 
			
		||||
@@ -34,7 +34,7 @@ server:
 | 
			
		||||
docker:
 | 
			
		||||
  services:
 | 
			
		||||
    database:
 | 
			
		||||
      enabled: true
 | 
			
		||||
      enabled:    true
 | 
			
		||||
    yourls: 
 | 
			
		||||
      version:    "latest"
 | 
			
		||||
      name:       "yourls"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,8 @@
 | 
			
		||||
{% include 'roles/docker-compose/templates/base.yml.j2' %}
 | 
			
		||||
 | 
			
		||||
  application:
 | 
			
		||||
{% set container_port = 80 %}
 | 
			
		||||
{% set container_healthcheck = 'http://127.0.0.1' ~ yourls_admin_location %}
 | 
			
		||||
    image: "{{ yourls_image }}:{{ yourls_version }}"
 | 
			
		||||
    container_name: "{{ yourls_container }}"
 | 
			
		||||
    image: "{{ YOURLS_IMAGE }}:{{ YOURLS_VERSION }}"
 | 
			
		||||
    container_name: "{{ YOURLS_CONTAINER }}"
 | 
			
		||||
{% include 'roles/docker-container/templates/base.yml.j2' %}
 | 
			
		||||
    ports:
 | 
			
		||||
      - "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_NAME: "{{ database_name }}"
 | 
			
		||||
YOURLS_SITE:    "{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
 | 
			
		||||
YOURLS_USER:    "{{ yourls_user }}"
 | 
			
		||||
YOURLS_PASS:    "{{ yourls_password }}"
 | 
			
		||||
YOURLS_USER:    "{{ YOURLS_USER }}"
 | 
			
		||||
YOURLS_PASS:    "{{ YOURLS_PASSWORD }}"
 | 
			
		||||
# 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))}}" 
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
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"
 | 
			
		||||
database_type:                  "mariadb"
 | 
			
		||||
 | 
			
		||||
# Yourls Specific
 | 
			
		||||
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
 | 
			
		||||
# Webserver
 | 
			
		||||
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; }",
 | 
			
		||||
                            ]
 | 
			
		||||
                        }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
            'app2': {}
 | 
			
		||||
@@ -55,6 +55,28 @@ class TestCspFilters(unittest.TestCase):
 | 
			
		||||
            '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):
 | 
			
		||||
        result = self.filter.get_csp_whitelist(self.apps, 'app1', 'script-src-elem')
 | 
			
		||||
        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')
 | 
			
		||||
        # Ensure core directives are present
 | 
			
		||||
        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 'self' https://matomo.example.org https://cdn.example.com",
 | 
			
		||||
            header
 | 
			
		||||
        )
 | 
			
		||||
        self.assertIn(
 | 
			
		||||
            "script-src 'self' 'unsafe-eval'",
 | 
			
		||||
            header
 | 
			
		||||
        )
 | 
			
		||||
        # connect-src directive unchanged (no inline hash)
 | 
			
		||||
        self.assertIn(
 | 
			
		||||
            "connect-src 'self' https://matomo.example.org https://api.example.com;",
 | 
			
		||||
            header
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # script-src-elem should include 'self', Matomo, internes CDN und explizite Whitelist-CDN
 | 
			
		||||
        self.assertIn("script-src-elem 'self'", header)
 | 
			
		||||
        self.assertIn("https://matomo.example.org", header)
 | 
			
		||||
        self.assertIn("https://cdn.example.org", header)   # internes CDN
 | 
			
		||||
        self.assertIn("https://cdn.example.com", header)   # Whitelist
 | 
			
		||||
 | 
			
		||||
        # script-src directive should include unsafe-eval
 | 
			
		||||
        self.assertIn("script-src 'self' 'unsafe-eval'", header)
 | 
			
		||||
 | 
			
		||||
        # connect-src directive (reihenfolgeunabhängig prüfen)
 | 
			
		||||
        tokens = self._get_directive_tokens(header, "connect-src")
 | 
			
		||||
        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
 | 
			
		||||
        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)
 | 
			
		||||
        # default-src only contains 'self'
 | 
			
		||||
        self.assertIn("default-src 'self';", header)
 | 
			
		||||
        # no external URLs
 | 
			
		||||
        self.assertNotIn('http', header)
 | 
			
		||||
        self.assertIn('https://cdn.example.org', header)
 | 
			
		||||
        self.assertNotIn('matomo.example.org', header)
 | 
			
		||||
        self.assertNotIn('www.google.com', header)
 | 
			
		||||
        # ends with img-src
 | 
			
		||||
        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; }")
 | 
			
		||||
        self.assertNotIn(style_hash, header)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def test_build_csp_header_recaptcha_toggle(self):
 | 
			
		||||
        """
 | 
			
		||||
        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']
 | 
			
		||||
        
 | 
			
		||||
        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(
 | 
			
		||||
            header,
 | 
			
		||||
            r"frame-ancestors\s+'self'\s+domain-example\.com;"
 | 
			
		||||
@@ -194,9 +219,9 @@ class TestCspFilters(unittest.TestCase):
 | 
			
		||||
        # Now disable the feature and rebuild
 | 
			
		||||
        self.apps['app1']['features']['desktop'] = False
 | 
			
		||||
        header_no = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
 | 
			
		||||
        # Should no longer contain the wildcarded sld.tld
 | 
			
		||||
        self.assertNotIn("*.domain-example.com", header_no)
 | 
			
		||||
        # Should no longer contain the SLD+TLD
 | 
			
		||||
        self.assertNotIn("domain-example.com", header_no)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    unittest.main()
 | 
			
		||||
    unittest.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