From e7702948b85b42a35116707b570094fffb6365d7 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 1 Oct 2025 14:08:09 +0200 Subject: [PATCH] EspoCRM role: custom image + single data volume + runtime flag setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Build a custom image and replace upstream entrypoint with docker-entrypoint-custom.sh (strict fail on flag script). • Introduce set_flags.php and wire via ESPOCRM_SET_FLAGS_SCRIPT; apply flags at container start; clear cache afterwards. • Keep exactly one Docker volume (data:/var/www/html/); drop separate custom/extensions mounts. • Compose: use custom image, add healthchecks & depends_on for daemon/websocket; keep service healthy gating. • Ansible: deploy scripts, build & up via handlers; patch siteUrl as www-data; run upgrade non-fatal; always run flag setter. • Vars/Env: add ESPO_INIT_* toggles and ESPOCRM_SET_FLAGS_SCRIPT; refactor variables for scripts & custom image paths. Conversation context: https://chatgpt.com/share/68dd1992-020c-800f-bcf5-2db60cb4aab2 --- roles/web-app-espocrm/config/main.yml | 3 +- .../files/docker-entrypoint-custom.sh | 60 +++++++++++++++++++ roles/web-app-espocrm/files/set_flags.php | 33 ++++++++++ .../web-app-espocrm/tasks/01_patch_config.yml | 56 +---------------- roles/web-app-espocrm/tasks/main.yml | 40 ++++++++++++- roles/web-app-espocrm/templates/Dockerfile.j2 | 9 +++ .../templates/docker-compose.yml.j2 | 35 ++++++++--- roles/web-app-espocrm/templates/env.j2 | 6 ++ roles/web-app-espocrm/vars/main.yml | 55 ++++++++++++----- 9 files changed, 216 insertions(+), 81 deletions(-) create mode 100644 roles/web-app-espocrm/files/docker-entrypoint-custom.sh create mode 100644 roles/web-app-espocrm/files/set_flags.php create mode 100644 roles/web-app-espocrm/templates/Dockerfile.j2 diff --git a/roles/web-app-espocrm/config/main.yml b/roles/web-app-espocrm/config/main.yml index 04c6043b..43127095 100644 --- a/roles/web-app-espocrm/config/main.yml +++ b/roles/web-app-espocrm/config/main.yml @@ -51,4 +51,5 @@ docker: mem_limit: 0.5g pids_limit: 384 volumes: - data: espocrm_data + data: espocrm_data +maintenance_mode: false \ No newline at end of file diff --git a/roles/web-app-espocrm/files/docker-entrypoint-custom.sh b/roles/web-app-espocrm/files/docker-entrypoint-custom.sh new file mode 100644 index 00000000..2e9408f6 --- /dev/null +++ b/roles/web-app-espocrm/files/docker-entrypoint-custom.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -euo pipefail + +log() { printf '%s %s\n' "[entrypoint]" "$*" >&2; } + +# --- Simple boolean normalization -------------------------------------------- +bool_norm () { + v="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" + case "$v" in + 1|true|yes|on) echo "true" ;; + 0|false|no|off|"") echo "false" ;; + *) echo "false" ;; + esac +} + +# Expected ENV (from env.j2) +MAINTENANCE="$(bool_norm "${ESPO_INIT_MAINTENANCE_MODE:-false}")" +CRON_DISABLED="$(bool_norm "${ESPO_INIT_CRON_DISABLED:-false}")" +USE_CACHE="$(bool_norm "${ESPO_INIT_USE_CACHE:-true}")" + +APP_DIR="/var/www/html" +SET_FLAGS_SCRIPT="${ESPOCRM_SET_FLAGS_SCRIPT}" + +# --- Wait for bootstrap.php (max 60s, e.g. fresh volume) ---------------------- +log "Waiting for ${APP_DIR}/bootstrap.php..." +for i in $(seq 1 60); do + [ -f "${APP_DIR}/bootstrap.php" ] && break + sleep 1 +done +if [ ! -f "${APP_DIR}/bootstrap.php" ]; then + log "ERROR: bootstrap.php missing after 60s"; exit 1 +fi + +# --- Apply config flags via set_flags.php ------------------------------------ +log "Applying runtime flags via set_flags.php..." +php "${SET_FLAGS_SCRIPT}" + +# --- Clear cache (safe) ------------------------------------------------------- +php "${APP_DIR}/clear_cache.php" || true + +# --- Hand off to CMD ---------------------------------------------------------- +if [ "$#" -gt 0 ]; then + log "Exec CMD: $*" + exec "$@" +fi + +# Try common server commands +for cmd in apache2-foreground httpd-foreground php-fpm php-fpm8.3 php-fpm8.2 supervisord; do + if command -v "$cmd" >/dev/null 2>&1; then + log "Starting: $cmd" + case "$cmd" in + php-fpm|php-fpm8.*) exec "$cmd" -F ;; + supervisord) exec "$cmd" -n ;; + *) exec "$cmd" ;; + esac + fi +done + +log "No known server command found; tailing to keep container alive." +exec tail -f /dev/null diff --git a/roles/web-app-espocrm/files/set_flags.php b/roles/web-app-espocrm/files/set_flags.php new file mode 100644 index 00000000..83007cd3 --- /dev/null +++ b/roles/web-app-espocrm/files/set_flags.php @@ -0,0 +1,33 @@ +getContainer(); +$cfg = $c->get("config"); +$w = $c->get("injectableFactory")->create("\Espo\Core\Utils\Config\ConfigWriter"); + +// Read from ENV +$flags = [ + "maintenanceMode" => in_array(strtolower(getenv("ESPO_INIT_MAINTENANCE_MODE") ?: "false"), ["1","true","yes","on"]), + "cronDisabled" => in_array(strtolower(getenv("ESPO_INIT_CRON_DISABLED") ?: "false"), ["1","true","yes","on"]), + "useCache" => in_array(strtolower(getenv("ESPO_INIT_USE_CACHE") ?: "true"), ["1","true","yes","on"]) +]; + +$changed = false; +foreach ($flags as $k => $v) { + if ($cfg->get($k) !== $v) { + $w->set($k, $v); + $changed = true; + } +} + +if ($changed) { + $w->save(); + echo "CHANGED\n"; +} else { + echo "UNCHANGED\n"; +} diff --git a/roles/web-app-espocrm/tasks/01_patch_config.yml b/roles/web-app-espocrm/tasks/01_patch_config.yml index 351c76e6..4cf2a859 100644 --- a/roles/web-app-espocrm/tasks/01_patch_config.yml +++ b/roles/web-app-espocrm/tasks/01_patch_config.yml @@ -37,7 +37,7 @@ - name: Ensure siteUrl matches canonical domain ansible.builtin.shell: | - {{ docker_compose_command_exec }} -T {{ ESPOCRM_SERVICE }} php -r ' + {{ docker_compose_command_exec }} -T --user {{ ESPOCRM_USER }} {{ ESPOCRM_SERVICE }} php -r ' require "/var/www/html/bootstrap.php"; $app = new \Espo\Core\Application(); $c = $app->getContainer(); @@ -54,57 +54,3 @@ chdir: "{{ docker_compose.directories.instance }}" register: siteurl_set changed_when: "'CHANGED' in siteurl_set.stdout" - -- name: Ensure maintenance off, cron on, cache on (idempotent via ConfigWriter) - block: - - name: Apply config via ConfigWriter as app user - command: > - docker exec --user {{ ESPOCRM_USER }} {{ ESPOCRM_CONTAINER }} - php -r ' - require "/var/www/html/bootstrap.php"; - $app = new \Espo\Core\Application(); - $c = $app->getContainer(); - $cfg = $c->get("config"); - $w = $c->get("injectableFactory")->create("\Espo\Core\Utils\Config\ConfigWriter"); - $pairs = [ - "maintenanceMode" => false, - "cronDisabled" => false, - "useCache" => true - ]; - $changed = false; - foreach ($pairs as $k => $v) { - if ($cfg->get($k) !== $v) { $w->set($k, $v); $changed = true; } - } - if ($changed) { $w->save(); echo "CHANGED"; } - ' - register: cfg_set - changed_when: "'CHANGED' in cfg_set.stdout" - - rescue: - - name: Apply config via ConfigWriter as root (fallback) - command: > - docker exec --user root {{ ESPOCRM_CONTAINER }} - php -r ' - require "/var/www/html/bootstrap.php"; - $app = new \Espo\Core\Application(); - $c = $app->getContainer(); - $cfg = $c->get("config"); - $w = $c->get("injectableFactory")->create("\Espo\Core\Utils\Config\ConfigWriter"); - $pairs = [ - "maintenanceMode" => false, - "cronDisabled" => false, - "useCache" => true - ]; - $changed = false; - foreach ($pairs as $k => $v) { - if ($cfg->get($k) !== $v) { $w->set($k, $v); $changed = true; } - } - if ($changed) { $w->save(); echo "CHANGED"; } - ' - register: cfg_set - changed_when: "'CHANGED' in cfg_set.stdout" - -- name: Clear EspoCRM cache (only when config changed and we are updating) - command: > - docker exec --user {{ ESPOCRM_USER }} {{ ESPOCRM_CONTAINER }} php clear_cache.php - when: "'CHANGED' in cfg_set.stdout and MODE_UPDATE | bool" diff --git a/roles/web-app-espocrm/tasks/main.yml b/roles/web-app-espocrm/tasks/main.yml index 3ee51cac..e5b120d2 100644 --- a/roles/web-app-espocrm/tasks/main.yml +++ b/roles/web-app-espocrm/tasks/main.yml @@ -3,17 +3,53 @@ include_role: name: sys-stk-full-stateful vars: - docker_compose_flush_handlers: true + docker_compose_flush_handlers: false + +- name: "Deploy '{{ ESPOCRM_ENTRYPOINT_SCRIPT_HOST_ABS }}'" + copy: + src: "{{ ESPOCRM_ENTRYPOINT_SCRIPT_FILE }}" + dest: "{{ ESPOCRM_ENTRYPOINT_SCRIPT_HOST_ABS }}" + notify: + - docker compose up + - docker compose build + +- name: "Deploy '{{ ESPOCRM_SET_FLAG_SCRIPT_HOST_ABS }}'" + copy: + src: "{{ ESPOCRM_SET_FLAG_SCRIPT_FILE }}" + dest: "{{ ESPOCRM_SET_FLAG_SCRIPT_HOST_ABS }}" + notify: + - docker compose up + - docker compose build + + +- name: "Docker Compose Up for '{{ application_id }}'" + meta: flush_handlers - name: Check if config.php exists in EspoCRM command: docker exec --user root {{ ESPOCRM_CONTAINER }} test -f {{ ESPOCRM_CONFIG_FILE_PRIVATE }} register: config_file_exists changed_when: false - failed_when: false + failed_when: false - name: Patch EspoCRM config.php include_tasks: 01_patch_config.yml when: config_file_exists.rc == 0 +- name: Run EspoCRM upgrade (only when MODE_UPDATE is true) + command: > + docker exec --user {{ ESPOCRM_USER }} {{ ESPOCRM_CONTAINER }} + php command.php upgrade -y + register: espocrm_upgrade + changed_when: "'Upgrading' in espocrm_upgrade.stdout or 'successfully' in espocrm_upgrade.stdout" + failed_when: false + when: MODE_UPDATE | bool + +- name: Run flag setter as root (fallback) + command: > + docker exec --user root {{ ESPOCRM_CONTAINER }} + php {{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }} + register: flags_result_root + changed_when: "'CHANGED' in flags_result_root.stdout" + - name: Flush handlers to make DB available before password reset meta: flush_handlers diff --git a/roles/web-app-espocrm/templates/Dockerfile.j2 b/roles/web-app-espocrm/templates/Dockerfile.j2 new file mode 100644 index 00000000..145d7f2e --- /dev/null +++ b/roles/web-app-espocrm/templates/Dockerfile.j2 @@ -0,0 +1,9 @@ +FROM "{{ ESPOCRM_IMAGE }}:{{ ESPOCRM_VERSION }}" + +COPY {{ ESPOCRM_SET_FLAG_SCRIPT_HOST_REL }} {{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }} +RUN chmod +x {{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }} + +COPY {{ ESPOCRM_ENTRYPOINT_SCRIPT_HOST_REL }} {{ ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER }} +RUN chmod +x {{ ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER }} + +ENTRYPOINT ["{{ ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER }}"] diff --git a/roles/web-app-espocrm/templates/docker-compose.yml.j2 b/roles/web-app-espocrm/templates/docker-compose.yml.j2 index 9628074e..1e3ca7db 100644 --- a/roles/web-app-espocrm/templates/docker-compose.yml.j2 +++ b/roles/web-app-espocrm/templates/docker-compose.yml.j2 @@ -2,8 +2,9 @@ {% set service_name = ESPOCRM_SERVICE %} {{ service_name }}: + {{ lookup('template', 'roles/docker-container/templates/build.yml.j2') | indent(4) }} container_name: {{ ESPOCRM_CONTAINER }} - image: "{{ ESPOCRM_IMAGE }}:{{ ESPOCRM_VERSION }}" + image: "{{ ESPOCRM_CUSTOM_IMAGE }}" init: true stop_signal: SIGTERM stop_grace_period: 30s @@ -14,12 +15,13 @@ {% include 'roles/docker-container/templates/depends_on/dmbs_excl.yml.j2' %} {% include 'roles/docker-container/templates/networks.yml.j2' %} volumes: - - data:/var/www/html + - data:/var/www/html/ {% set service_name = 'daemon' %} {{ service_name }}: - image: "{{ ESPOCRM_IMAGE }}:{{ ESPOCRM_VERSION }}" + image: "{{ ESPOCRM_CUSTOM_IMAGE }}" container_name: {{ ESPOCRM_CONTAINER }}_{{ service_name }} + pull_policy: never init: true stop_signal: SIGTERM stop_grace_period: 30s @@ -27,12 +29,22 @@ entrypoint: docker-daemon.sh {% include 'roles/docker-container/templates/networks.yml.j2' %} volumes: - - data:/var/www/html + - data:/var/www/html/ + depends_on: + {{ ESPOCRM_SERVICE }}: + condition: service_healthy + healthcheck: + test: ["CMD", "php", "-r", "require '/var/www/html/bootstrap.php'; echo 'OK';"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 40s {% set service_name = 'websocket' %} {{ service_name }}: - image: "{{ ESPOCRM_IMAGE }}:{{ ESPOCRM_VERSION }}" + image: "{{ ESPOCRM_CUSTOM_IMAGE }}" container_name: {{ ESPOCRM_CONTAINER }}_{{ service_name }} + pull_policy: never init: true stop_signal: SIGTERM stop_grace_period: 30s @@ -46,12 +58,21 @@ {% include 'roles/docker-container/templates/depends_on/dmbs_excl.yml.j2' %} {% include 'roles/docker-container/templates/networks.yml.j2' %} volumes: - - data:/var/www/html + - data:/var/www/html/ ports: - "127.0.0.1:{{ ports.localhost.websocket[application_id] }}:8080" + depends_on: + {{ ESPOCRM_SERVICE }}: + condition: service_healthy + healthcheck: + test: ["CMD", "sh", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && echo 'OK' && exec 3<&- 3>&-"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 40s {% include 'roles/docker-compose/templates/volumes.yml.j2' %} data: - name: {{ ESPOCRM_VOLUME }} + name: {{ ESPOCRM_DATA_VOLUME }} {% include 'roles/docker-compose/templates/networks.yml.j2' %} \ No newline at end of file diff --git a/roles/web-app-espocrm/templates/env.j2 b/roles/web-app-espocrm/templates/env.j2 index ee6f9000..6b8c0ae4 100644 --- a/roles/web-app-espocrm/templates/env.j2 +++ b/roles/web-app-espocrm/templates/env.j2 @@ -103,3 +103,9 @@ ESPOCRM_CONFIG_OIDC_USERNAME_CLAIM={{ OIDC.ATTRIBUTES.USERNAME }} # ESPOCRM_CONFIG_OIDC_SYNC_TEAMS=true # ESPOCRM_CONFIG_OIDC_GROUP_CLAIM=group {% endif %} + +# --- Espo init toggles controlled at container start (used by custom entrypoint) +ESPO_INIT_MAINTENANCE_MODE=false # false = disable maintenance (recommended) +ESPO_INIT_CRON_DISABLED=false # false = enable cron jobs in config +ESPO_INIT_USE_CACHE=true # true = enable caching +ESPOCRM_SET_FLAGS_SCRIPT={{ ESPOCRM_SET_FLAG_SCRIPT_DOCKER }} diff --git a/roles/web-app-espocrm/vars/main.yml b/roles/web-app-espocrm/vars/main.yml index 387d4eaf..106e5e28 100644 --- a/roles/web-app-espocrm/vars/main.yml +++ b/roles/web-app-espocrm/vars/main.yml @@ -1,24 +1,47 @@ # General -application_id: "web-app-espocrm" -entity_name: "{{ application_id | get_entity_name }}" +application_id: "web-app-espocrm" +entity_name: "{{ application_id | get_entity_name }}" # Database -database_type: "mariadb" +database_type: "mariadb" # Webserver -location_ws: "/ws" -ws_port: "{{ ports.localhost.websocket[application_id] }}" -client_max_body_size: "100m" -vhost_flavour: "ws_generic" +location_ws: "/ws" +ws_port: "{{ ports.localhost.websocket[application_id] }}" +client_max_body_size: "100m" +vhost_flavour: "ws_generic" # Espocrm -ESPOCRM_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.espocrm.version') }}" -ESPOCRM_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.espocrm.image') }}" -ESPOCRM_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.espocrm.name') }}" -ESPOCRM_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}" -ESPOCRM_SERVICE: "{{ entity_name }}" -ESPOCRM_CONFIG_FILE_PRIVATE: "/var/www/html/data/config-internal.php" -ESPOCRM_URL: "{{ domains | get_url(application_id, WEB_PROTOCOL) }}" -ESPOCRM_OIDC_ENABLED: "{{ applications | get_app_conf(application_id, 'features.oidc') }}" -ESPOCRM_USER: "www-data" +## Container +ESPOCRM_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.' ~ entity_name ~'.version') }}" +ESPOCRM_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.' ~ entity_name ~'.image') }}" +ESPOCRM_CUSTOM_IMAGE: "custom_espocrm" +ESPOCRM_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.' ~ entity_name ~'.name') }}" +ESPOCRM_SERVICE: "{{ entity_name }}" + +## Volumes +ESPOCRM_DATA_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}" + +## Scripts + +### Entrypoint +ESPOCRM_ENTRYPOINT_SCRIPT_FILE: "docker-entrypoint-custom.sh" +ESPOCRM_ENTRYPOINT_SCRIPT_HOST_ABS: "{{ [ docker_compose.directories.volumes, ESPOCRM_ENTRYPOINT_SCRIPT_FILE ] | path_join }}" +ESPOCRM_ENTRYPOINT_SCRIPT_HOST_REL: "volumes/{{ ESPOCRM_ENTRYPOINT_SCRIPT_FILE }}" +ESPOCRM_ENTRYPOINT_SCRIPT_DOCKER: "{{ [ '/usr/local/bin/', ESPOCRM_ENTRYPOINT_SCRIPT_FILE ] | path_join }}" + +### Set Flag +ESPOCRM_SET_FLAG_SCRIPT_FILE: "set_flags.php" +ESPOCRM_SET_FLAG_SCRIPT_HOST_ABS: "{{ [ docker_compose.directories.volumes, ESPOCRM_SET_FLAG_SCRIPT_FILE ] | path_join }}" +ESPOCRM_SET_FLAG_SCRIPT_HOST_REL: "volumes/{{ ESPOCRM_SET_FLAG_SCRIPT_FILE }}" +ESPOCRM_SET_FLAG_SCRIPT_DOCKER: "{{ [ '/usr/local/bin/', ESPOCRM_SET_FLAG_SCRIPT_FILE ] | path_join }}" + +ESPOCRM_CONFIG_FILE_PRIVATE: "/var/www/html/data/config-internal.php" +ESPOCRM_URL: "{{ domains | get_url(application_id, WEB_PROTOCOL) }}" +ESPOCRM_OIDC_ENABLED: "{{ applications | get_app_conf(application_id, 'features.oidc') }}" +ESPOCRM_USER: "www-data" + +ESPO_INIT_MAINTENANCE_MODE: "{{ applications | get_app_conf(application_id, 'maintenance_mode') | default(false) }}" +ESPO_INIT_CRON_DISABLED: "{{ ESPO_INIT_MAINTENANCE_MODE }}" # disable cron only when in maintenance +ESPO_INIT_USE_CACHE: "{{ not ESPO_INIT_MAINTENANCE_MODE }}" # enable cache when NOT in maintenance \ No newline at end of file