feat(shopware): migrate to single Shopware base image and split services (web/worker/scheduler/init)

• Introduce init container and runtime-ready Dockerfile (Alpine) installing php83-gd/intl/pdo_mysql
• Disable composer scripts in builder and ignore build-time ext reqs
• New docker-compose template (web/worker/scheduler/opensearch) + persistent volumes
• Use TRUSTED_PROXIES env; fix APP_URL formatting; set OPENSEARCH_HOST=opensearch
• Replace SHOPWARE_PHP_CONTAINER refs with SHOPWARE_WEB_CONTAINER in tasks
• Render and copy init.sh via volumes path
• Remove old nginx/php split and legacy DB env task
• Fix svc-db-postgres var: database_type now uses entity_name
https://chatgpt.com/share/6907fc58-7c28-800f-a993-c207f28859c9
This commit is contained in:
2025-11-03 01:51:38 +01:00
parent d6f3618d70
commit f5efbce205
15 changed files with 293 additions and 104 deletions

View File

@@ -17,22 +17,41 @@ docker:
services:
database:
enabled: true
php:
init:
name: software-init
cpus: 1.0
mem_reservation: 1g
mem_limit: 2g
# Base PHP image used by all app services
shopware:
image: "ghcr.io/shopware/docker-base"
version: "8.3"
name: "shopware-php"
cpus: 2.0
mem_reservation: 2g
mem_limit: 4g
pids_limit: 2048
nginx:
image: "nginx"
version: "alpine"
name: "shopware-nginx"
port: 8080
web:
name: "shopware-web"
port: 8000
cpus: 1.0
mem_reservation: 1g
mem_limit: 2g
worker:
name: "shopware-worker"
entrypoint: [ "php", "bin/console", "messenger:consume", "async", "low_priority", "--time-limit=300", "--memory-limit=512M" ]
replicas: 3
cpus: 1.0
mem_reservation: 1g
mem_limit: 2g
scheduler:
name: "shopware-scheduler"
entrypoint: [ "php", "bin/console", "scheduled-task:run" ]
cpus: 0.5
mem_reservation: 256m
mem_limit: 512m
mem_reservation: 512m
mem_limit: 1g
redis:
enabled: true
image: "redis"
@@ -40,6 +59,7 @@ docker:
cpus: 0.25
mem_reservation: 256m
mem_limit: 512m
opensearch:
enabled: true
image: "opensearchproject/opensearch"
@@ -48,5 +68,6 @@ docker:
cpus: 1.0
mem_reservation: 2g
mem_limit: 4g
volumes:
data: "shopware_data"

View File

@@ -1,10 +0,0 @@
---
- name: Flush handlers to ensure containers are up before rendering .env
meta: flush_handlers
- name: Render .env for Shopware
template:
src: "templates/env.j2"
dest: "{{ docker_compose.directories.instance }}/.env"
mode: "0640"
notify: docker compose up

View File

@@ -1,6 +1,6 @@
- name: "Deactivate/uninstall LDAP plugin if present"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
cd {{ SHOPWARE_ROOT }}
php bin/console plugin:deactivate INFX_LDAP_PLUGIN || true
php bin/console plugin:uninstall INFX_LDAP_PLUGIN --keep-user-data || true

View File

@@ -1,6 +1,6 @@
- name: "Deactivate/uninstall OIDC plugin if present"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
cd {{ SHOPWARE_ROOT }}
php bin/console plugin:deactivate INFX_OIDC_PLUGIN || true
php bin/console plugin:uninstall INFX_OIDC_PLUGIN --keep-user-data || true

View File

@@ -2,6 +2,19 @@
- name: "Load docker, DB and proxy for {{ application_id }}"
include_role:
name: sys-stk-full-stateful
vars:
docker_compose_flush_handlers: false
- name: "Deploy {{ SHOPWARE_INIT_HOST }}"
template:
src: init.sh.j2
dest: "{{ SHOPWARE_INIT_HOST }}"
notify:
- docker compose up
- docker compose build
- name: "Flush docker compose handlers"
meta: flush_handlers
- name: Wait for Shopware HTTP endpoint
wait_for:
@@ -10,39 +23,9 @@
delay: 5
timeout: 300
- name: Render environment and DB settings
include_tasks: 01_database.yml
- name: "Run Shopware install / migrations"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console system:install --basic-setup --create-database --force
php bin/console database:migrate --all
php bin/console database:migrate-destructive --all
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"
register: migrate
changed_when: migrate.rc == 0
- name: "Create initial admin user (idempotent)"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console user:create "{{ users.administrator.username }}" \
--admin --password="{{ users.administrator.password }}" \
--firstName="Admin" --lastName="User" --email="{{ users.administrator.email }}" || true
'
args:
chdir: "{{ docker_compose.directories.instance }}"
- name: "Warm up caches and index"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
cd {{ SHOPWARE_ROOT }}
php bin/console messenger:consume --time-limit=60 --limit=100 || true
php bin/console dal:refresh:index || true
@@ -52,20 +35,8 @@
chdir: "{{ docker_compose.directories.instance }}"
- name: Execute setup routines (OIDC/LDAP)
include_tasks: 02_setup.yml
include_tasks: 01_setup.yml
- name: Execute cleanup routines
include_tasks: 03_cleanup.yml
include_tasks: 02_cleanup.yml
when: MODE_CLEANUP
- name: Register DNS records for Shopware domain(s)
include_role:
name: sys-dns-cloudflare-records
vars:
cloudflare_records:
- zone: "{{ domains | get_domain(application_id) | to_zone }}"
type: A
name: "{{ domains | get_domain(application_id) }}"
content: "{{ networks.internet.ip4 }}"
proxied: true
when: DNS_PROVIDER == 'cloudflare'

View File

@@ -1,7 +1,7 @@
# Replace INFX_LDAP_PLUGIN with the actual plugin name you use
- name: "Install LDAP admin plugin & activate"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console plugin:refresh
@@ -13,7 +13,7 @@
- name: "Configure LDAP connection"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console system:config:set "InfxLdap.config.host" "{{ LDAP.SERVER.DOMAIN }}"

View File

@@ -1,7 +1,7 @@
# Replace INFX_OIDC_PLUGIN with the actual plugin name (Composer or local)
- name: "Install OIDC plugin & activate"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console plugin:refresh
@@ -13,7 +13,7 @@
- name: "Configure OIDC via system:config"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_PHP_CONTAINER }} bash -lc '
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console system:config:set "InfxOidc.config.clientId" "{{ OIDC.CLIENT.ID }}"

View File

@@ -0,0 +1,90 @@
# ------------------------------------------------------------------------------
# Shopware Application Image (Alpine-compatible)
# ------------------------------------------------------------------------------
# - Stage 1 (builder): use Composer to fetch Shopware while ignoring build-time
# PHP extensions (we'll install them in the runtime image).
# - Stage 2 (runtime): install required PHP extensions and copy the app + init.sh
# ------------------------------------------------------------------------------
############################
# Stage 1: Builder
############################
FROM composer:2.7 AS builder
ENV COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_NO_INTERACTION=1 \
COMPOSER_PROCESS_TIMEOUT=900
WORKDIR /app
ARG SHOPWARE_PROD_VERSION=shopware/production:6.7.3.1
# 1) Scaffold without installing
RUN set -eux; \
composer create-project "${SHOPWARE_PROD_VERSION}" /app --no-install
# 2) Install deps WITHOUT running scripts and ignoring build-time ext reqs
RUN set -eux; \
composer install \
--no-dev \
--optimize-autoloader \
--no-progress \
--no-scripts \
--ignore-platform-req=ext-gd \
--ignore-platform-req=ext-intl \
--ignore-platform-req=ext-pdo_mysql
############################
# Stage 2: Runtime
############################
FROM ghcr.io/shopware/docker-base:8.3
WORKDIR /var/www/html
# Install required PHP extensions in the Alpine-based runtime
# (try php83-*, fall back to php82-*, then to generic)
USER root
RUN set -eux; \
apk add --no-cache php83-gd || apk add --no-cache php82-gd || apk add --no-cache php-gd || true; \
apk add --no-cache php83-intl || apk add --no-cache php82-intl || apk add --no-cache php-intl || true; \
apk add --no-cache php83-pdo_mysql || apk add --no-cache php82-pdo_mysql || apk add --no-cache php-pdo_mysql || true
# Copy built application from the builder
COPY --chown=www-data:www-data --from=builder /app /var/www/html
# Optional: snapshot of pristine app to seed an empty volume (used by init container)
RUN mkdir -p /usr/src/shopware \
&& cp -a /var/www/html/. /usr/src/shopware/. \
&& chown -R www-data:www-data /var/www/html /usr/src/shopware
# Ensure writable directories exist with correct ownership
RUN set -eux; \
mkdir -p \
/var/www/html/files \
/var/www/html/var \
/var/www/html/public/media \
/var/www/html/public/thumbnail \
/var/www/html/public/sitemap \
/var/www/html/public/theme; \
chown -R www-data:www-data /var/www/html
# Add trusted proxies wiring (Symfony reads env TRUSTED_PROXIES)
RUN set -eux; \
mkdir -p /var/www/html/config/packages; \
if [ ! -f /var/www/html/config/packages/framework.yaml ]; then \
printf "framework:\n trusted_proxies: '%%env(TRUSTED_PROXIES)%%'\n" > /var/www/html/config/packages/framework.yaml; \
fi
# Copy the init script that your Compose mounts as volumes/init.sh in the build context
COPY --chown=www-data:www-data volumes/init.sh /usr/local/bin/init.sh
RUN chmod +x /usr/local/bin/init.sh
# Drop back to the app user
USER www-data
# Default envs (override via .env / compose env_file)
ENV APP_ENV=prod \
APP_URL=http://localhost:8000 \
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Expose internal port & add a lightweight healthcheck
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=5 --start-period=20s \
CMD php -r '$s=@fsockopen("127.0.0.1", 8000, $e, $t, 3); if(!$s) exit(1); fclose($s);'

View File

@@ -1,36 +1,81 @@
x-environment: &shopware
image: "{{ SHOPWARE_CUSTOM_IMAGE }}"
volumes:
- files:/var/www/html/files
- theme:/var/www/html/public/theme
- media:/var/www/html/public/media
- thumbnail:/var/www/html/public/thumbnail
- sitemap:/var/www/html/public/sitemap
working_dir: {{ SHOPWARE_ROOT }}
{% include 'roles/docker-compose/templates/base.yml.j2' %}
{% set service_name = 'php' %}
# -------------------------
# INIT (runs once per deployment)
# -------------------------
{% set service_name = 'init' %}
{% set docker_restart_policy = 'no' %}
{{ service_name }}:
{% include 'roles/docker-container/templates/base.yml.j2' %}
image: "{{ SHOPWARE_PHP_IMAGE }}:{{ SHOPWARE_VERSION }}"
container_name: "{{ SHOPWARE_PHP_CONTAINER }}"
working_dir: /var/www/html
volumes:
- data:/var/www/html
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{% include 'roles/docker-container/templates/depends_on/dmbs_excl.yml.j2' %}
{% set docker_restart_policy = DOCKER_RESTART_POLICY %}
<<: *shopware
container_name: "{{ SHOPWARE_INIT_CONTAINER }}"
entrypoint: [ "sh", "/usr/local/bin/init.sh" ]
{% set service_name = 'nginx' %}
{% set container_port = 80 %}
{# -------------------------
WEB (serves HTTP on 8000)
------------------------- #}
{% set service_name = 'web' %}
{% set container_port = applications | get_app_conf(application_id, 'docker.services.web.port') %}
{{ service_name }}:
{% include 'roles/docker-container/templates/base.yml.j2' %}
image: "{{ SHOPWARE_NGINX_IMAGE }}:{{ SHOPWARE_NGINX_VERSION }}"
container_name: "{{ SHOPWARE_NGINX_CONTAINER }}"
<<: *shopware
{{ lookup('template', 'roles/docker-container/templates/build.yml.j2') | indent(4) }}
container_name: "{{ SHOPWARE_WEB_CONTAINER }}"
ports:
- "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}"
volumes:
- data:/var/www/html:ro
depends_on:
- php
init:
condition: service_completed_successfully
{% filter indent(4) %}
{% include 'roles/docker-container/templates/healthcheck/http.yml.j2' %}
{% endfilter %}
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{# -------------------------
WORKER (async queues)
------------------------- #}
{% set service_name = 'worker' %}
{{ service_name }}:
{% include 'roles/docker-container/templates/base.yml.j2' %}
<<: *shopware
container_name: "{{ SHOPWARE_WORKER_CONTAINER }}"
pull_policy: never
entrypoint: {{ SHOPWARE_WORKER_ENTRYPOINT }}
depends_on:
init:
condition: service_completed_successfully
# @todo Activate for swarm deploy
# deploy:
# replicas: {{ SHOPWARE_WORKER_REPLICAS }}
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{# -------------------------
SCHEDULER (cron-like)
------------------------- #}
{% set service_name = 'scheduler' %}
{{ service_name }}:
{% include 'roles/docker-container/templates/base.yml.j2' %}
<<: *shopware
container_name: "{{ SHOPWARE_SCHED_CONTAINER }}"
pull_policy: never
entrypoint: {{ SHOPWARE_SCHED_ENTRYPOINT }}
depends_on:
init:
condition: service_completed_successfully
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{% if SHOPWARE_OPENSEARCH_ENABLED %}
{% set service_name = 'opensearch' %}
{{ service_name }}:
@@ -44,11 +89,24 @@
- OPENSEARCH_JAVA_OPTS=-Xms{{ SHOPWARE_OPENSEARCH_MEM_RESERVATION }} -Xmx{{ SHOPWARE_OPENSEARCH_MEM_RESERVATION }}
ulimits:
memlock: { soft: -1, hard: -1 }
depends_on:
init:
condition: service_completed_successfully
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{% endif %}
{% include 'roles/docker-compose/templates/volumes.yml.j2' %}
data:
name: {{ SHOPWARE_VOLUME }}
files:
name: {{ entity_name }}_files
theme:
name: {{ entity_name }}_theme
media:
name: {{ entity_name }}_media
thumbnail:
name: {{ entity_name }}_thumbnail
sitemap:
name: {{ entity_name }}_sitemap
{% include 'roles/docker-compose/templates/networks.yml.j2' %}

View File

@@ -1,10 +1,10 @@
# DOMAIN/URL
DOMAIN={{ domains | get_domain(application_id) }}
APP_URL="{{ domains | get_url(application_id, WEB_PROTOCOL) }}/"
APP_URL="{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
# Shopware
APP_ENV={{ 'dev' if (ENVIRONMENT | lower) == 'development' else 'prod' }}
APP_URL_TRUSTED_PROXIES=127.0.0.1
TRUSTED_PROXIES=127.0.0.1
INSTANCE_ID={{ application_id }}
# Database
@@ -19,12 +19,11 @@ MESSENGER_TRANSPORT_DSN="redis://{{ SHOPWARE_REDIS_ADDRESS }}/2"
CACHE_URL="file://cache"
{% endif %}
{% if SHOPWARE_OPENSEARCH_ENABLED %}
# Search
ELASTICSEARCH_URL="http://opensearch:9200"
OPENSEARCH_URL="http://opensearch:9200"
OPENSEARCH_HOST="search"
OPENSEARCH_HOST="opensearch"
OPENSEARCH_PORT_NUMBER="9200"
OPENSEARCH_INITIAL_ADMIN_PASSWORD="{{ users.administrator.password }}"
{% endif %}

View File

@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
cd {{ SHOPWARE_ROOT }}
mkdir -p {{ SHOPWARE_ROOT }}/.infinito
MARKER="{{ SHOPWARE_ROOT }}/.infinito/installed"
echo "[INIT] Checking database via PDO..."
php -r '
$url = getenv("DATABASE_URL");
if (!$url) { fwrite(STDERR, "DATABASE_URL not set\n"); exit(1); }
$p = parse_url($url);
if (!$p || !isset($p["scheme"])) { fwrite(STDERR, "Invalid DATABASE_URL\n"); exit(1); }
$scheme = $p["scheme"];
if ($scheme === "mysql" || $scheme === "mariadb") {
$host = $p["host"] ?? "localhost";
$port = $p["port"] ?? 3306;
$db = ltrim($p["path"] ?? "", "/");
$user = $p["user"] ?? "";
$pass = $p["pass"] ?? "";
$dsn = "mysql:host=".$host.";port=".$port.";dbname=".$db.";charset=utf8mb4";
} else {
fwrite(STDERR, "Unsupported DB scheme: ".$scheme."\n"); exit(1);
}
$retries = 60;
while ($retries-- > 0) {
try { $pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_TIMEOUT => 3]); exit(0); }
catch (Exception $e) { sleep(2); }
}
fwrite(STDERR, "DB not reachable\n"); exit(1);
'
if [ ! -f "$MARKER" ]; then
echo "[INIT] Installing Shopware..."
php -d memory_limit=1024M bin/console system:install --basic-setup --create-database --force
php -d memory_limit=1024M bin/console database:migrate --all
php -d memory_limit=1024M bin/console database:migrate-destructive --all
php bin/console user:create "{{ users.administrator.username }}" \
--admin --password="{{ users.administrator.password }}" \
--firstName="Admin" --lastName="User" --email="{{ users.administrator.email }}" || true
php bin/console cache:clear || true
php bin/console dal:refresh:index || true
touch "$MARKER"
chown -R {{ SHOPWARE_USER }}:{{ SHOPWARE_USER }} {{ SHOPWARE_ROOT }}
echo "[INIT] Done."
else
echo "[INIT] Marker found, skipping install."
fi

View File

@@ -1,22 +1,33 @@
# General
application_id: "web-app-shopware"
database_type: "mariadb"
entity_name: "{{ application_id | get_entity_name }}"
# Docker
container_port: "{{ applications | get_app_conf(application_id, 'docker.services.nginx.port') }}"
container_port: "{{ applications | get_app_conf(application_id, 'docker.services.web.port') }}"
docker_compose_flush_handlers: true
# Shopware container/image vars
SHOPWARE_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.php.version') }}"
SHOPWARE_PHP_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.php.image') }}"
SHOPWARE_PHP_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.php.name') }}"
SHOPWARE_NGINX_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.nginx.image') }}"
SHOPWARE_NGINX_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.nginx.version') }}"
SHOPWARE_NGINX_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.nginx.name') }}"
SHOPWARE_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.shopware.version') }}"
SHOPWARE_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.shopware.image') }}"
SHOPWARE_CUSTOM_IMAGE: "{{ SHOPWARE_IMAGE }}:{{ SHOPWARE_VERSION }}"
SHOPWARE_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}"
SHOPWARE_USER: "www-data"
SHOPWARE_ROOT: "/var/www/html"
# Split service container names
SHOPWARE_INIT_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.init.name') }}"
SHOPWARE_WEB_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.web.name') }}"
SHOPWARE_WORKER_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.worker.name') }}"
SHOPWARE_SCHED_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.scheduler.name') }}"
SHOPWARE_INIT_HOST: "{{ [ docker_compose.directories.volumes, 'init.sh' ] | path_join }}"
SHOPWARE_INIT_DOCKER: "/usr/local/bin/init.sh"
# Entrypoints & replicas
SHOPWARE_WORKER_ENTRYPOINT: "{{ applications | get_app_conf(application_id, 'docker.services.worker.entrypoint') }}"
SHOPWARE_SCHED_ENTRYPOINT: "{{ applications | get_app_conf(application_id, 'docker.services.scheduler.entrypoint') }}"
SHOPWARE_WORKER_REPLICAS: "{{ applications | get_app_conf(application_id, 'docker.services.worker.replicas') }}"
# Search/Cache
SHOPWARE_REDIS_ENABLED: "{{ applications | get_app_conf(application_id, 'docker.services.redis.enabled') }}"
SHOPWARE_REDIS_ADDRESS: "redis:6379"
@@ -29,4 +40,4 @@ SHOPWARE_OPENSEARCH_MEM_RESERVATION: "{{ applications | get_app_conf(applicatio
SHOPWARE_OPENSEARCH_MEM_LIMIT: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.mem_limit') }}"
# IAM (true if either OIDC or LDAP is enabled)
SHOPWARE_IAM_ENABLED: "{{ applications | get_app_conf(application_id, 'features.oidc') or applications | get_app_conf(application_id, 'features.ldap') }}"
SHOPWARE_IAM_ENABLED: "{{ applications | get_app_conf(application_id, 'features.oidc') or applications | get_app_conf(application_id, 'features.ldap') }}"