From ca52dcda43e0dd45f072a075d1a3d9c09cec630c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 24 Sep 2025 17:22:47 +0200 Subject: [PATCH] Refactor OpenProject role: - Add CPU, memory and PID limits to all services in config/main.yml to prevent OOM - Replace old LDAP admin bootstrap with new 02_admin.yml using OPENPROJECT_ADMINISTRATOR_* vars - Standardize variable names (uppercase convention) - Fix HTTPS/HSTS port check (443 instead of 433) - Allow docker_restart_policy override in base.yml.j2 - Cleanup redundant LDAP admin runner in 01_ldap.yml See: https://chatgpt.com/share/68d40c6e-ab9c-800f-a4a0-d9338d8c1b32 --- roles/docker-container/templates/base.yml.j2 | 2 +- roles/web-app-openproject/config/main.yml | 36 ++++++++++-- roles/web-app-openproject/tasks/01_ldap.yml | 22 ------- roles/web-app-openproject/tasks/02_admin.yml | 30 ++++++++++ roles/web-app-openproject/tasks/main.yml | 19 ++++--- .../templates/Dockerfile.j2 | 2 +- .../templates/docker-compose.yml.j2 | 46 ++++++++------- roles/web-app-openproject/templates/env.j2 | 4 +- roles/web-app-openproject/vars/ldap.yml | 4 +- roles/web-app-openproject/vars/main.yml | 57 +++++++++++-------- 10 files changed, 134 insertions(+), 88 deletions(-) create mode 100644 roles/web-app-openproject/tasks/02_admin.yml diff --git a/roles/docker-container/templates/base.yml.j2 b/roles/docker-container/templates/base.yml.j2 index aa9d57bd..db26d552 100644 --- a/roles/docker-container/templates/base.yml.j2 +++ b/roles/docker-container/templates/base.yml.j2 @@ -1,6 +1,6 @@ {# Base for docker services #} - restart: {{ DOCKER_RESTART_POLICY }} + restart: {{ docker_restart_policy | default(DOCKER_RESTART_POLICY) }} {% if application_id | has_env %} env_file: - "{{ docker_compose.files.env }}" diff --git a/roles/web-app-openproject/config/main.yml b/roles/web-app-openproject/config/main.yml index d5c1c5b9..fa24c87b 100644 --- a/roles/web-app-openproject/config/main.yml +++ b/roles/web-app-openproject/config/main.yml @@ -42,18 +42,44 @@ docker: version: "13" # Update when available. No rolling release implemented backup: no_stop_required: true + cpus: "1.0" + mem_reservation: "1.5g" + mem_limit: "2g" + pids_limit: 512 seeder: name: openproject-seeder + cpus: "0.3" + mem_reservation: "256m" + mem_limit: "512m" + pids_limit: 256 cron: - name: openproject-cron + name: openproject-cron + cpus: "0.3" + mem_reservation: "256m" + mem_limit: "512m" + pids_limit: 256 worker: - name: openproject-worker + name: openproject-worker + cpus: "0.8" + mem_reservation: "1g" + mem_limit: "1.5g" + pids_limit: 512 proxy: name: openproject-proxy + cpus: "0.3" + mem_reservation: "256m" + mem_limit: "512m" + pids_limit: 256 cache: - name: openproject-cache - image: "" # If need a specific memcached image you have to define it here, otherwise the version from svc-db-memcached will be used - version: "" # If need a specific memcached version you have to define it here, otherwise the version from svc-db-memcached will be used + name: openproject-cache + image: "" # If need a specific memcached image you have to define it here, otherwise the version from svc-db-memcached will be used + version: "" # If need a specific memcached version you have to define it here, otherwise the version from svc-db-memcached will be used + cpus: "0.3" + mem_reservation: "256m" + mem_limit: "512m" + pids_limit: 256 volumes: data: "openproject_data" + + diff --git a/roles/web-app-openproject/tasks/01_ldap.yml b/roles/web-app-openproject/tasks/01_ldap.yml index cd592307..e2f4a144 100644 --- a/roles/web-app-openproject/tasks/01_ldap.yml +++ b/roles/web-app-openproject/tasks/01_ldap.yml @@ -77,25 +77,3 @@ when: ldap_check.query_result | length == 0 async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - -# This works just after the first admin login -# @todo Remove and replace trough LDAP RBAC group -- name: Set LDAP user as admin via OpenProject Rails runner - shell: > - docker compose exec web bash -c " - cd /app && - RAILS_ENV={{ ENVIRONMENT | lower }} bundle exec rails runner \" - user = User.find_by(mail: '{{ users.administrator.email }}'); - if user.nil?; - puts 'User with email {{ users.administrator.email }} not found.'; - else; - user.admin = true; - user.save!; - puts 'User \#{user.login} is now an admin.'; - end - \" - " - args: - chdir: "{{ docker_compose.directories.instance }}" - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" \ No newline at end of file diff --git a/roles/web-app-openproject/tasks/02_admin.yml b/roles/web-app-openproject/tasks/02_admin.yml new file mode 100644 index 00000000..f34a7fad --- /dev/null +++ b/roles/web-app-openproject/tasks/02_admin.yml @@ -0,0 +1,30 @@ +- name: Ensure administrator user exists and is admin + shell: > + docker compose exec web bash -c " + cd /app && + RAILS_ENV={{ ENVIRONMENT | lower }} bundle exec rails runner \" + u = User.find_by(login: '{{ OPENPROJECT_ADMINISTRATOR_USERNAME }}') + if u.nil? + u = User.new( + login: '{{ OPENPROJECT_ADMINISTRATOR_USERNAME }}', + mail: '{{ OPENPROJECT_ADMINISTRATOR_EMAIL }}', + firstname: 'Admin', + lastname: 'User', + password: '{{ OPENPROJECT_ADMINISTRATOR_PASSWORD }}', + password_confirmation: '{{ OPENPROJECT_ADMINISTRATOR_PASSWORD }}' + ) + u.admin = true + u.save! + puts 'Administrator {{ OPENPROJECT_ADMINISTRATOR_USERNAME }} created and set as admin.' + else + u.admin = true + u.save! + puts 'User {{ OPENPROJECT_ADMINISTRATOR_USERNAME }} updated to admin.' + end + \" + " + args: + chdir: "{{ docker_compose.directories.instance }}" + async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" diff --git a/roles/web-app-openproject/tasks/main.yml b/roles/web-app-openproject/tasks/main.yml index 42a219c4..614ff4a9 100644 --- a/roles/web-app-openproject/tasks/main.yml +++ b/roles/web-app-openproject/tasks/main.yml @@ -3,23 +3,23 @@ include_role: name: sys-stk-full-stateful -- name: "Create {{ openproject_plugins_folder }}" +- name: "Create {{ OPENPROJECT_PLUGINS_FOLDER }}" file: - path: "{{ openproject_plugins_folder }}" + path: "{{ OPENPROJECT_PLUGINS_FOLDER }}" state: directory mode: '0755' -- name: "Transfering Gemfile.plugins to {{ openproject_plugins_folder }}" +- name: "Transfering Gemfile.plugins to {{ OPENPROJECT_PLUGINS_FOLDER }}" copy: src: Gemfile.plugins - dest: "{{ openproject_plugins_folder }}Gemfile.plugins" + dest: "{{ OPENPROJECT_PLUGINS_FOLDER }}Gemfile.plugins" notify: - docker compose up - docker compose build -- name: "create {{ openproject_dummy_volume }}" +- name: "create {{ OPENPROJECT_DUMMY_VOLUME }}" file: - path: "{{ openproject_dummy_volume }}" + path: "{{ OPENPROJECT_DUMMY_VOLUME }}" state: directory mode: "0755" @@ -32,11 +32,14 @@ RAILS_ENV={{ ENVIRONMENT | lower }} bundle exec rails runner \"Setting[:{{ item.key }}] = '{{ item.value }}'\"" args: chdir: "{{ docker_compose.directories.instance }}" - loop: "{{ openproject_rails_settings | dict2items }}" + loop: "{{ OPENPROJECT_RAILS_SETTINGS | dict2items }}" async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - name: Setup LDAP include_tasks: 01_ldap.yml - when: applications | get_app_conf(application_id, 'features.ldap', True) | bool \ No newline at end of file + when: OPENPROJECT_LDAP_ENABLED | bool + +- name: Create OpenProject Administrator + include_tasks: 02_admin.yml \ No newline at end of file diff --git a/roles/web-app-openproject/templates/Dockerfile.j2 b/roles/web-app-openproject/templates/Dockerfile.j2 index 4e428859..d82396b4 100644 --- a/roles/web-app-openproject/templates/Dockerfile.j2 +++ b/roles/web-app-openproject/templates/Dockerfile.j2 @@ -1,4 +1,4 @@ -FROM {{ openproject_image }}:{{ openproject_version }} +FROM {{ OPENPROJECT_IMAGE }}:{{ OPENPROJECT_VERSION }} # If installing a local plugin (using `path:` in the `Gemfile.plugins` above), # you will have to copy the plugin code into the container here and use the diff --git a/roles/web-app-openproject/templates/docker-compose.yml.j2 b/roles/web-app-openproject/templates/docker-compose.yml.j2 index be986df2..54bbb0f4 100644 --- a/roles/web-app-openproject/templates/docker-compose.yml.j2 +++ b/roles/web-app-openproject/templates/docker-compose.yml.j2 @@ -2,20 +2,22 @@ x-op-app: &app logging: driver: journald - image: {{ openproject_custom_image }} + image: {{ OPENPROJECT_CUSTOM_IMAGE }} {{ lookup('template', 'roles/docker-container/templates/build.yml.j2') | indent(2) }} {% include 'roles/docker-compose/templates/base.yml.j2' %} cache: - image: "{{ openproject_cache_image}}:{{ openproject_cache_version }}" - container_name: {{ openproject_cache_name }} +{% set service_name = 'cache' %} + image: "{{ OPENPROJECT_CACHE_IMAGE}}:{{ OPENPROJECT_CACHE_VERSION }}" + container_name: {{ OPENPROJECT_CACHE_CONTAINER }} {% include 'roles/docker-container/templates/base.yml.j2' %} proxy: +{% set service_name = 'proxy' %} {% include 'roles/docker-container/templates/base.yml.j2' %} - image: {{ openproject_custom_image }} - container_name: {{ openproject_proxy_name }} + image: {{ OPENPROJECT_CUSTOM_IMAGE }} + container_name: {{ OPENPROJECT_PROXY_CONTAINER }} pull_policy: never command: "./docker/prod/proxy" ports: @@ -26,13 +28,14 @@ x-op-app: &app - web volumes: - "data:/var/openproject/assets" - - "{{ openproject_dummy_volume }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes + - "{{ OPENPROJECT_DUMMY_VOLUME }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes web: <<: *app +{% set service_name = 'web' %} {% include 'roles/docker-container/templates/base.yml.j2' %} command: "./docker/prod/web" - container_name: {{ openproject_web_name }} + container_name: {{ OPENPROJECT_WEB_CONTAINER }} {% include 'roles/docker-container/templates/networks.yml.j2' %} {% include 'roles/docker-container/templates/depends_on/dmbs_incl.yml.j2' %} cache: @@ -44,13 +47,14 @@ x-op-app: &app {% include 'roles/docker-container/templates/healthcheck/curl.yml.j2' %} volumes: - "data:/var/openproject/assets" - - "{{ openproject_dummy_volume }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes - + - "{{ OPENPROJECT_DUMMY_VOLUME }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes + +{% set service_name = 'worker' %} worker: <<: *app {% include 'roles/docker-container/templates/base.yml.j2' %} command: "./docker/prod/worker" - container_name: {{ openproject_worker_name }} + container_name: {{ OPENPROJECT_WORKER_CONTAINER }} {% include 'roles/docker-container/templates/networks.yml.j2' %} {% include 'roles/docker-container/templates/depends_on/dmbs_incl.yml.j2' %} cache: @@ -59,14 +63,14 @@ x-op-app: &app condition: service_started volumes: - "data:/var/openproject/assets" - - "{{ openproject_dummy_volume }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes - + - "{{ OPENPROJECT_DUMMY_VOLUME }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes cron: <<: *app +{% set service_name = 'cron' %} {% include 'roles/docker-container/templates/base.yml.j2' %} command: "./docker/prod/cron" - container_name: {{ openproject_cron_name }} + container_name: {{ OPENPROJECT_CRON_CONTAINER }} {% include 'roles/docker-container/templates/networks.yml.j2' %} {% include 'roles/docker-container/templates/depends_on/dmbs_incl.yml.j2' %} cache: @@ -75,24 +79,22 @@ x-op-app: &app condition: service_started volumes: - "data:/var/openproject/assets" - - "{{ openproject_dummy_volume }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes + - "{{ OPENPROJECT_DUMMY_VOLUME }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes seeder: <<: *app +{% set service_name = 'seeder' %} +{% set docker_restart_policy = 'on-failure' %} +{% include 'roles/docker-container/templates/base.yml.j2' %} command: "./docker/prod/seeder" - container_name: {{ openproject_seeder_name }} - env_file: - - "{{ docker_compose.files.env }}" - logging: - driver: journald - restart: on-failure + container_name: {{ OPENPROJECT_SEEDER_CONTAINER }} {% include 'roles/docker-container/templates/networks.yml.j2' %} volumes: - "data:/var/openproject/assets" - - "{{ openproject_dummy_volume }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes + - "{{ OPENPROJECT_DUMMY_VOLUME }}:/var/openproject/pgdata" # This mount is unnecessary and just done to prevent anonymous volumes {% include 'roles/docker-compose/templates/volumes.yml.j2' %} data: - name: {{ openproject_volume }} + name: {{ OPENPROJECT_VOLUME }} {% include 'roles/docker-compose/templates/networks.yml.j2' %} \ No newline at end of file diff --git a/roles/web-app-openproject/templates/env.j2 b/roles/web-app-openproject/templates/env.j2 index 12e6b034..8372797b 100644 --- a/roles/web-app-openproject/templates/env.j2 +++ b/roles/web-app-openproject/templates/env.j2 @@ -6,11 +6,11 @@ # Please refer to our documentation to see all possible variables: # https://www.openproject.org/docs/installation-and-operations/configuration/environment/ # -OPENPROJECT_HTTPS={{ WEB_PORT == 433 | string | lower }} +OPENPROJECT_HTTPS={{ WEB_PORT == 443 | string | lower }} OPENPROJECT_HOST__NAME={{ domains | get_domain(application_id) }} OPENPROJECT_RAILS__RELATIVE__URL__ROOT= IMAP_ENABLED=false -OPENPROJECT_HSTS={{ WEB_PORT == 433 | string | lower }} +OPENPROJECT_HSTS={{ WEB_PORT == 443 | string | lower }} RAILS_CACHE_STORE: "memcache" OPENPROJECT_CACHE__MEMCACHE__SERVER: "cache:11211" OPENPROJECT_RAILS__RELATIVE__URL__ROOT: "" diff --git a/roles/web-app-openproject/vars/ldap.yml b/roles/web-app-openproject/vars/ldap.yml index c2e6a969..dfba65d3 100644 --- a/roles/web-app-openproject/vars/ldap.yml +++ b/roles/web-app-openproject/vars/ldap.yml @@ -9,9 +9,9 @@ openproject_ldap: attr_firstname: "givenName" # LDAP attribute for first name attr_lastname: "{{ LDAP.USER.ATTRIBUTES.SURNAME }}" # LDAP attribute for last name attr_mail: "{{ LDAP.USER.ATTRIBUTES.MAIL }}" # LDAP attribute for email - attr_admin: "{{ openproject_filters.administrators }}" # Optional: LDAP attribute for admin group (leave empty if unused) + attr_admin: "{{ OPENPROJECT_LDAP_FILTERS.ADMINISTRATORS }}" # Optional: LDAP attribute for admin group (leave empty if unused) onthefly_register: true # Automatically create users on first login tls_mode: 0 # 0 = No TLS, 1 = TLS, 2 = STARTTLS verify_peer: false # Whether to verify the SSL certificate - filter_string: "{{ openproject_filters.users }}" # Optional: Custom filter for users (e.g., "(objectClass=person)") + filter_string: "{{ OPENPROJECT_LDAP_FILTERS.USERS }}" # Optional: Custom filter for users (e.g., "(objectClass=person)") tls_certificate_string: "" # Optional: Client certificate string for TLS (usually left empty) \ No newline at end of file diff --git a/roles/web-app-openproject/vars/main.yml b/roles/web-app-openproject/vars/main.yml index 1273df1d..f006b509 100644 --- a/roles/web-app-openproject/vars/main.yml +++ b/roles/web-app-openproject/vars/main.yml @@ -4,39 +4,50 @@ application_id: "web-app-openproject" # Database database_type: "postgres" +# Docker +docker_repository_branch: "stable/{{ OPENPROJECT_VERSION }}" +docker_repository_address: "https://github.com/opf/openproject-deploy" +docker_pull_git_repository: true +docker_compose_flush_handlers: false + # Open Project Specific -openproject_version: "{{ applications | get_app_conf(application_id, 'docker.services.web.version') }}" -openproject_image: "{{ applications | get_app_conf(application_id, 'docker.services.web.image') }}" -openproject_volume: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}" -openproject_web_name: "{{ applications | get_app_conf(application_id, 'docker.services.web.name') }}" -openproject_seeder_name: "{{ applications | get_app_conf(application_id, 'docker.services.seeder.name') }}" -openproject_cron_name: "{{ applications | get_app_conf(application_id, 'docker.services.cron.name') }}" -openproject_proxy_name: "{{ applications | get_app_conf(application_id, 'docker.services.proxy.name') }}" -openproject_worker_name: "{{ applications | get_app_conf(application_id, 'docker.services.worker.name') }}" +OPENPROJECT_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.web.version') }}" +OPENPROJECT_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.web.image') }}" +OPENPROJECT_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}" +OPENPROJECT_WEB_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.web.name') }}" +OPENPROJECT_SEEDER_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.seeder.name') }}" +OPENPROJECT_CRON_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.cron.name') }}" +OPENPROJECT_PROXY_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.proxy.name') }}" +OPENPROJECT_WORKER_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.worker.name') }}" + +## Admin +OPENPROJECT_ADMINISTRATOR_USERNAME: "{{ users.administrator.username }}" +OPENPROJECT_ADMINISTRATOR_PASSWORD: "{{ users.administrator.password }}" +OPENPROJECT_ADMINISTRATOR_EMAIL: "{{ users.administrator.email }}" # Open Project Cache -openproject_cache_name: "{{ applications | get_app_conf(application_id, 'docker.services.cache.name') }}" -openproject_cache_image: "{{ applications +OPENPROJECT_CACHE_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.cache.name') }}" +OPENPROJECT_CACHE_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.cache.image') or applications | get_app_conf('svc-db-memcached', 'docker.services.memcached.image') }}" -openproject_cache_version: "{{ applications +OPENPROJECT_CACHE_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.cache.version') or applications | get_app_conf('svc-db-memcached', 'docker.services.memcached.version') }}" -openproject_plugins_folder: "{{ docker_compose.directories.volumes }}plugins/" +OPENPROJECT_PLUGINS_FOLDER: "{{ docker_compose.directories.volumes }}plugins/" -openproject_custom_image: "custom_openproject" +OPENPROJECT_CUSTOM_IMAGE: "custom_openproject" # The following volume doesn't have a practcical function. It just exist to prevent the creation of unnecessary anonymous volumes -openproject_dummy_volume: "{{ docker_compose.directories.volumes }}dummy_volume" +OPENPROJECT_DUMMY_VOLUME: "{{ docker_compose.directories.volumes }}dummy_volume" -openproject_rails_settings: +OPENPROJECT_RAILS_SETTINGS: email_delivery_method: "smtp" smtp_address: "{{ SYSTEM_EMAIL.HOST }}" smtp_domain: "{{ SYSTEM_EMAIL.DOMAIN }}" @@ -44,15 +55,11 @@ openproject_rails_settings: smtp_password: "{{ users['no-reply'].mailu_token }}" smtp_ssl: false -openproject_filters: - administrators: "{{ '(memberOf=cn=openproject-admins,' ~ LDAP.DN.OU.ROLES ~ ')' +## LDAP +OPENPROJECT_LDAP_ENABLED: "{{ applications | get_app_conf(application_id, 'features.ldap') }}" +OPENPROJECT_LDAP_FILTERS: + # The administrator filter just works in the Enterprise edition + ADMINISTRATORS: "{{ '(memberOf=cn=openproject-admins,' ~ LDAP.DN.OU.ROLES ~ ')' if applications | get_app_conf(application_id, 'ldap.filters.administrators') else '' }}" - - users: "{{ '(memberOf=cn=openproject-users,' ~ LDAP.DN.OU.ROLES ~ ')' + USERS: "{{ '(memberOf=cn=openproject-users,' ~ LDAP.DN.OU.ROLES ~ ')' if applications | get_app_conf(application_id, 'ldap.filters.users') else '' }}" - -# Docker -docker_repository_branch: "stable/{{ openproject_version }}" -docker_repository_address: "https://github.com/opf/openproject-deploy" -docker_pull_git_repository: true -docker_compose_flush_handlers: false \ No newline at end of file