11 Commits

Author SHA1 Message Date
df8390f386 Refactor category sorting in docker_cards_grouped lookup plugin, restructure Shopware task sequence, and extend menu categories (Commerce, Storage). Added unit tests for lookup plugin.
Conversation reference: https://chatgpt.com/share/6908642f-29cc-800f-89ec-fd6de9892b44
2025-11-03 09:14:15 +01:00
48557b06e3 refactor(web-app-shopware): make init script idempotent and handle admin via Ansible
- moved init.sh from template to files/ for direct copying and bind mounting
- removed hardcoded user creation from init process
- added database emptiness check before running system:install
- added new task 03_admin.yml to ensure admin user exists and update password/email via Ansible
- switched docker exec shell from bash to sh for Alpine compatibility
- updated Dockerfile and docker-compose.yml accordingly for mount-based init script
2025-11-03 03:36:13 +01:00
1cff5778d3 Activated debugging 2025-11-03 02:42:52 +01:00
60e2c972d6 Fix Shopware Docker build: add Redis support and align network includes
- Added symfony/redis-messenger installation with ignored build-time PHP extension checks
- Installed php83-redis in runtime stage
- Ensured consistent network includes across all Shopware services in docker-compose template
- Improves compatibility with Redis-based Symfony Messenger transport during init phase

https://chatgpt.com/share/6908068e-0bb8-800f-8855-7b3913c57158
2025-11-03 02:34:51 +01:00
637de6a190 Added network to init 2025-11-03 02:00:36 +01:00
f5efbce205 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
2025-11-03 01:51:38 +01:00
d6f3618d70 Add reusable HTTP healthcheck template and integrate into Shopware and Taiga roles 2025-11-02 22:26:42 +01:00
773655efb5 Used correct image and deactivated oidc and ldap 2025-11-02 21:40:03 +01:00
7bc9f7abd9 Refactor Shopware role to use dedicated OpenSearch service and improved environment handling.
Changes include:
- Added OpenSearch configuration and variable definitions (image, version, heap, memory limits)
- Replaced legacy search/elasticsearch logic with OpenSearch integration
- Updated docker-compose template for OpenSearch with proper JVM heap and ulimits
- Ensured both OPENSEARCH_URL and ELASTICSEARCH_URL are set for compatibility

Reference: https://chatgpt.com/share/6907b0d4-ab14-800f-b576-62c0d26c8ad1
2025-11-02 21:05:52 +01:00
ec7b8662dd Implemented service name 2025-11-02 20:36:20 +01:00
d1ccfd9cdd Add new Shopware 6 role with OIDC/LDAP plugin integration and Docker-based deployment configuration.
Includes:
- New role: web-app-shopware (Docker, MariaDB, Redis, OpenSearch)
- Updated networks and ports configuration
- Automated install, migration, and admin creation
- Optional IAM integration via OIDC/LDAP plugins

Reference: https://chatgpt.com/share/6907b0d4-ab14-800f-b576-62c0d26c8ad1
2025-11-02 20:29:13 +01:00
48 changed files with 841 additions and 630 deletions

View File

@@ -114,7 +114,7 @@ defaults_networks:
subnet: 192.168.104.48/28
web-app-mini-qr:
subnet: 192.168.104.64/28
web-app-drupal:
web-app-shopware:
subnet: 192.168.104.80/28
# /24 Networks / 254 Usable Clients

View File

@@ -81,7 +81,7 @@ ports:
web-app-minio_api: 8057
web-app-minio_console: 8058
web-app-mini-qr: 8059
web-app-drupal: 8060
web-app-shopware: 8060
web-app-bigbluebutton: 48087 # This port is predefined by bbb. @todo Try to change this to a 8XXX port
public:
# The following ports should be changed to 22 on the subdomain via stream mapping

View File

@@ -0,0 +1,31 @@
{# ------------------------------------------------------------------------------
Healthcheck: HTTP Local
------------------------------------------------------------------------------
This template defines a generic HTTP healthcheck for containers exposing
a web service on a local port (e.g., Nginx, Apache, PHP-FPM, Shopware, etc.).
It uses `wget` or `curl` (as fallback) to test if the container responds on
http://127.0.0.1:{{ container_port }}/. If the request succeeds, Docker marks
the container as "healthy"; otherwise, as "unhealthy".
Parameters:
- container_port: The internal port the service listens on.
Timing:
- interval: 30s → Check every 30 seconds
- timeout: 5s → Each check must complete within 5 seconds
- retries: 5 → Mark unhealthy after 5 consecutive failures
- start_period: 20s → Grace period before health checks begin
Usage:
{% filter indent(4) %}
{% include 'roles/docker-container/templates/healthcheck/http.yml.j2' %}
{% endfilter %}
------------------------------------------------------------------------------
#}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:{{ container_port }}/ >/dev/null || curl -fsS http://127.0.0.1:{{ container_port }}/ >/dev/null"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s

View File

@@ -6,7 +6,7 @@ entity_name: "{{ application_id | get_entity_name }
docker_compose_flush_handlers: true
# Docker Compose
database_type: "{{ application_id | get_entity_name }}"
database_type: "{{ entity_name }}"
## Postgres
POSTGRES_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}"

View File

@@ -4,11 +4,13 @@ __metaclass__ = type
from ansible.plugins.lookup import LookupBase
from ansible.errors import AnsibleError
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
"""
Group the given cards into categorized and uncategorized lists
based on the tags from menu_categories.
Categories are sorted alphabetically before returning.
"""
if len(terms) < 2:
raise AnsibleError("Missing required arguments")
@@ -19,6 +21,7 @@ class LookupModule(LookupBase):
categorized = {}
uncategorized = []
# Categorize cards
for card in cards:
found = False
for category, data in menu_categories.items():
@@ -29,10 +32,14 @@ class LookupModule(LookupBase):
if not found:
uncategorized.append(card)
# Sort categories alphabetically
sorted_categorized = {
k: categorized[k] for k in sorted(categorized.keys(), key=str.lower)
}
return [
{
'categorized': categorized,
'categorized': sorted_categorized,
'uncategorized': uncategorized,
}
]

View File

@@ -25,7 +25,6 @@ portfolio_menu_categories:
- ollama
- openwebui
- flowise
- minio
- qdrant
- litellm
@@ -102,14 +101,12 @@ portfolio_menu_categories:
- fusiondirectory
- user-management
Customer Relationship Management:
description: "Tools for managing customer relationships, sales pipelines, marketing, and support activities."
Customer Relationship:
description: "Customer Relationship Management (CRM) software for managing customer relationships, sales pipelines, marketing, and support activities."
icon: "fa-solid fa-address-book"
tags:
- crm
- customer
- relationship
- sales
- marketing
- support
- espocrm
@@ -222,7 +219,7 @@ portfolio_menu_categories:
- snipe-it
Content Management:
description: "CMS and web publishing platforms"
description: "Content Management Systems (CMS) and web publishing platforms"
icon: "fa-solid fa-file-alt"
tags:
- cms
@@ -231,4 +228,27 @@ portfolio_menu_categories:
- website
- joomla
- wordpress
- blog
- blog
Commerce:
description: "Platforms for building and managing online shops, product catalogs, and digital sales channels — including payment, inventory, and customer features."
icon: "fa-solid fa-cart-shopping"
tags:
- commerce
- ecommerce
- shopware
- shop
- sales
- store
- magento
- pretix
Storage:
description: "High-performance, self-hosted storage solutions for managing, scaling, and accessing unstructured data — including object storage compatible with Amazon S3 APIs."
icon: "fa-solid fa-database"
tags:
- storage
- object-storage
- s3
- minio
- datasets

View File

@@ -1,29 +0,0 @@
# Administration
## Shell access
```bash
docker-compose exec -it application /bin/bash
```
## Drush (inside the container)
```bash
drush --version
drush cr # Cache rebuild
drush status # Site status
drush cim -y # Config import (if using config sync)
drush updb -y # Run DB updates
```
## Database access (local DB service)
```bash
docker-compose exec -it database /bin/mysql -u drupal -p
```
## Test Email
```bash
docker-compose exec -it application /bin/bash -lc 'echo "Test Email" | sendmail -v your-email@example.com'
```

View File

@@ -1,32 +0,0 @@
# Drupal
## Description
[Drupal](https://www.drupal.org/) is a powerful open-source CMS for building secure, extensible, and content-rich digital experiences.
This role deploys a containerized **Drupal 10/11** instance optimized for production, including **msmtp** for outbound email, **Drush** for CLI administration, and **OpenID Connect (OIDC)** for SSO (e.g., Keycloak, Auth0, Azure AD).
## Overview
* **Flexible Content Model:** Entities, fields, and views for complex data needs.
* **Security & Roles:** Fine-grained access control and active security team.
* **Robust Ecosystem:** Thousands of modules and themes.
* **CLI Automation:** Drush for installs, updates, and configuration import.
* **OIDC SSO:** First-class login via external Identity Providers.
This automated Docker Compose deployment builds a custom Drupal image with Drush and msmtp, wires database credentials and config overrides via environment, and applies OIDC configuration via Ansible/Drush.
## OIDC
This role enables **OpenID Connect** via the `openid_connect` module and configures a **client entity** (e.g., `keycloak`) including endpoints and scopes. Global OIDC behavior (auto-create, link existing users, privacy) is set via `openid_connect.settings`.
## Further Resources
* [Drupal.org](https://www.drupal.org/)
* [OpenID Connect module](https://www.drupal.org/project/openid_connect)
## Credits
Developed and maintained by **Kevin Veen-Birkenbach**
Learn more at [veen.world](https://veen.world)
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code)
License: [Infinito.Nexus NonCommercial License](https://s.infinito.nexus/license)

View File

@@ -1,41 +0,0 @@
title: "Site"
max_upload_size: "256M"
features:
matomo: true
css: false
desktop: true
oidc: true
central_database: true
logout: true
server:
csp:
flags: {}
whitelist: {}
domains:
canonical:
- "drupal.{{ PRIMARY_DOMAIN }}"
aliases: []
docker:
services:
database:
enabled: true
drupal:
# Use a PHP 8.2+ base image to ensure compatibility with OIDC 2.x syntax
version: "10-php8.2-apache"
image: drupal
name: drupal
backup:
no_stop_required: true
volumes:
data: drupal_data
rbac:
roles:
authenticated:
description: "Logged-in user"
content_editor:
description: "Can create and edit content"
site_admin:
description: "Full site administration"

View File

@@ -1,23 +0,0 @@
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: >
Drupal CMS in Docker with Drush, msmtp, and OpenID Connect (OIDC) SSO.
license: "Infinito.Nexus NonCommercial License"
license_url: "https://s.infinito.nexus/license"
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
galaxy_tags:
- drupal
- docker
- cms
- oidc
- sso
repository: "https://s.infinito.nexus/code"
issue_tracker_url: "https://s.infinito.nexus/issues"
documentation: "https://docs.infinito.nexus"
logo:
class: "fa-solid fa-droplet"
run_after:
- web-app-keycloak

View File

@@ -1,9 +0,0 @@
credentials:
administrator_password:
description: "Initial password for the Drupal admin account"
algorithm: "sha256"
validation: "^[a-f0-9]{64}$"
hash_salt:
description: "Drupal hash_salt value used for one-time logins, CSRF tokens, etc."
algorithm: "sha256"
validation: "^[a-f0-9]{64}$"

View File

@@ -1,11 +0,0 @@
- name: "Ensure sites/default/files exists and is writable"
command: >
docker exec -u root {{ DRUPAL_CONTAINER }} bash -lc
"set -e;
d='{{ DRUPAL_DOCKER_HTML_PATH }}/sites/default';
f=\"$d/files\";
mkdir -p \"$f\";
chown -R {{ DRUPAL_USER }}:{{ DRUPAL_USER }} \"$d\";
find \"$d\" -type d -exec chmod 775 {} +;
find \"$d\" -type f -exec chmod 664 {} +;"
changed_when: true

View File

@@ -1,25 +0,0 @@
- name: "Ensure settings.php exists and includes settings.local.php"
command: >
docker exec -u root {{ DRUPAL_CONTAINER }} bash -lc
"set -e;
f='{{ DRUPAL_DOCKER_CONF_PATH }}/settings.php';
df='{{ DRUPAL_DOCKER_CONF_PATH }}/default.settings.php';
if [ ! -f \"$f\" ] && [ -f \"$df\" ]; then
cp \"$df\" \"$f\";
chown www-data:www-data \"$f\";
chmod 644 \"$f\";
fi;
php -r '
$f=\"{{ DRUPAL_DOCKER_CONF_PATH }}/settings.php\";
if (!file_exists($f)) { exit(0); }
$c=file_get_contents($f);
$inc=\"\\nif (file_exists(\\\"\$app_root/\$site_path/settings.local.php\\\")) { include \$app_root/\$site_path/settings.local.php; }\\n\";
if (strpos($c, \"settings.local.php\") === false) {
file_put_contents($f, $c.$inc);
echo \"patched\";
} else {
echo \"exists\";
}
'"
register: settings_local_include
changed_when: "'patched' in settings_local_include.stdout"

View File

@@ -1,32 +0,0 @@
- name: "Wait for database readiness"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"php -r '
$h=\"{{ database_host }}\"; $p={{ database_port }};
$d=\"{{ database_name }}\"; $u=\"{{ database_username }}\"; $pw=\"{{ database_password }}\";
$t=microtime(true)+60; $ok=false;
while (microtime(true)<$t) {
try { new PDO(\"mysql:host=$h;port=$p;dbname=$d;charset=utf8mb4\", $u, $pw,[PDO::ATTR_TIMEOUT=>2]); $ok=true; break; }
catch (Exception $e) { usleep(300000); }
}
if (!$ok) { fwrite(STDERR, \"DB not ready\\n\"); exit(1); }'"
register: db_wait
retries: 1
failed_when: db_wait.rc != 0
- name: "Run Drupal site:install via Drush"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"/opt/drupal/vendor/bin/drush -r {{ DRUPAL_DOCKER_HTML_PATH }} site:install standard -y
--db-url='mysql://{{ database_username }}:{{ database_password }}@{{ database_host }}:{{ database_port }}/{{ database_name }}'
--site-name='{{ applications | get_app_conf(application_id, 'title', True) }}'
--account-name='{{ applications | get_app_conf(application_id, 'users.administrator.username') }}'
--account-mail='{{ applications | get_app_conf(application_id, 'users.administrator.email', True) }}'
--account-pass='{{ applications | get_app_conf(application_id, 'credentials.administrator_password', True) }}'
--uri='{{ DRUPAL_URL }}'"
args:
chdir: "{{ docker_compose.directories.instance }}"
register: drupal_install
changed_when: "'Installation complete' in drupal_install.stdout"
failed_when: false

View File

@@ -1,12 +0,0 @@
- name: "Enable OpenID Connect core module"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"drush -r {{ DRUPAL_DOCKER_HTML_PATH }} en openid_connect -y"
changed_when: true
- name: "Enable OpenID Connect Keycloak preset (submodule of openid_connect)"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"drush -r {{ DRUPAL_DOCKER_HTML_PATH }} en openid_connect_client_keycloak -y"
changed_when: true
failed_when: false

View File

@@ -1,79 +0,0 @@
- name: "Load OIDC vars"
include_vars:
file: "{{ role_path }}/vars/oidc.yml"
name: oidc_vars
- name: "Apply openid_connect.settings (global)"
loop: "{{ oidc_vars.oidc_settings | dict2items }}"
loop_control:
label: "{{ item.key }}"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"drush -r {{ DRUPAL_DOCKER_HTML_PATH }} cset -y
openid_connect.settings {{ item.key }}
{{ (item.value | to_json) if item.value is mapping or item.value is sequence else item.value }}"
- name: "Ensure/Update OIDC client entity (generic)"
vars:
client_id: "{{ oidc_vars.oidc_client.id }}"
client_label: "{{ oidc_vars.oidc_client.label }}"
plugin_id: "{{ oidc_vars.oidc_client.plugin }}"
settings_b64: "{{ oidc_vars.oidc_client.settings | to_json | b64encode }}"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"drush -r {{ DRUPAL_DOCKER_HTML_PATH }} eval '
$id=\"{{ client_id }}\";
$label=\"{{ client_label }}\";
$plugin=\"{{ plugin_id }}\";
$settings=json_decode(base64_decode(\"{{ settings_b64 }}\"), TRUE);
$storage=\\Drupal::entityTypeManager()->getStorage(\"openid_connect_client\");
$e=$storage->load($id);
if (!$e) {
$e=$storage->create([
\"id\"=> $id,
\"label\"=> $label,
\"status\"=> TRUE,
\"plugin\"=> $plugin,
\"settings\"=> $settings,
]);
$e->save();
print \"created\";
} else {
$e->set(\"label\", $label);
$e->set(\"plugin\", $plugin);
$e->set(\"settings\", $settings);
$e->set(\"status\", TRUE);
$e->save();
print \"updated\";
}
'"
register: client_apply
changed_when: "'created' in client_apply.stdout or 'updated' in client_apply.stdout"
- name: "Apply OIDC client settings"
vars:
client_id: "{{ oidc_vars.oidc_client.id }}"
settings_map: "{{ oidc_vars.oidc_client.settings }}"
kv: "{{ settings_map | dict2items }}"
loop: "{{ kv }}"
loop_control:
label: "{{ item.key }}"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"drush -r {{ DRUPAL_DOCKER_HTML_PATH }} eval '
$id=\"{{ client_id }}\";
$key=\"{{ item.key }}\";
$val=json_decode(base64_decode(\"{{ (item.value | to_json | b64encode) }}\"), true);
$storage=\Drupal::entityTypeManager()->getStorage(\"openid_connect_client\");
$c=$storage->load($id);
$s=$c->get(\"settings\");
$s[$key]=$val;
$c->set(\"settings\", $s);
$c->save();'"
changed_when: true
- name: "Clear caches after OIDC config"
command: >
docker exec {{ DRUPAL_CONTAINER }} bash -lc
"drush -r {{ DRUPAL_DOCKER_HTML_PATH }} cr"
changed_when: false

View File

@@ -1,19 +0,0 @@
- name: "Set trusted_host_patterns for canonical domains"
vars:
patterns: "{{ DRUPAL_DOMAINS
| map('regex_replace','\\\\.','\\\\\\\\.')
| map('regex_replace','^','^')
| map('regex_replace','$','$')
| list }}"
php_array: "{{ patterns | to_json }}"
command: >
docker exec -u root {{ DRUPAL_CONTAINER }} bash -lc
"php -r '
$f="{{ DRUPAL_DOCKER_CONF_PATH }}/settings.local.php";
$c=file_exists($f)?file_get_contents($f):"<?php\n";
// Remove existing assignment of $settings[\"trusted_host_patterns\"] (if any)
$c=preg_replace(\"/(\\\\$settings\\['trusted_host_patterns'\\]\\s*=).*?;/s\", \"\", $c);
$c.="\n\$settings[\'trusted_host_patterns\'] = ".var_export(json_decode("{{ php_array|e }}", true), true).";\n";
file_put_contents($f,$c);
'"
changed_when: true

View File

@@ -1,58 +0,0 @@
- name: "Include role sys-stk-front-proxy for {{ application_id }}"
include_role:
name: sys-stk-front-proxy
loop: "{{ DRUPAL_DOMAINS }}"
loop_control:
loop_var: domain
vars:
proxy_extra_configuration: "client_max_body_size {{ DRUPAL_MAX_UPLOAD_SIZE }};"
http_port: "{{ ports.localhost.http[application_id] }}"
- name: "Load docker and DB for {{ application_id }}"
include_role:
name: sys-stk-back-stateful
vars:
docker_compose_flush_handlers: false
- name: "Transfer upload.ini to {{ DRUPAL_CONFIG_UPLOAD_ABS }}"
template:
src: upload.ini.j2
dest: "{{ DRUPAL_CONFIG_UPLOAD_ABS }}"
notify:
- docker compose up
- docker compose build
- name: "Transfer msmtprc to {{ DRUPAL_MSMTP_ABS }}"
template:
src: "{{ DRUPAL_MSMTP_SRC }}"
dest: "{{ DRUPAL_MSMTP_ABS }}"
notify: docker compose up
- name: "Transfer settings.local.php overrides"
template:
src: settings.local.php.j2
dest: "{{ DRUPAL_SETTINGS_LOCAL_ABS }}"
notify: docker compose up
- name: Flush handlers to make container ready
meta: flush_handlers
- name: "Fix permissions for sites/default/files"
include_tasks: 00_permissions.yml
- name: "Ensure settings.php includes settings.local.php"
include_tasks: 01_settings_local_include.yml
- name: "Install Drupal (site:install)"
include_tasks: 02_install.yml
- name: "Enable OIDC modules"
include_tasks: 03_enable_modules.yml
when: applications | get_app_conf(application_id, 'features.oidc')
- name: "Configure OIDC (global + client)"
include_tasks: 04_configure_oidc.yml
when: applications | get_app_conf(application_id, 'features.oidc')
- name: "Harden trusted host patterns"
include_tasks: 05_trusted_hosts.yml

View File

@@ -1,83 +0,0 @@
FROM {{ DRUPAL_IMAGE }}:{{ DRUPAL_VERSION }}
# -------------------------------------------------------------------
# System dependencies (mail support + MySQL client + basic tools)
# -------------------------------------------------------------------
RUN apt-get update && \
apt-get install -y msmtp msmtp-mta git unzip zip less nano curl vim mariadb-client && \
rm -rf /var/lib/apt/lists/*
# -------------------------------------------------------------------
# PHP extensions required by Drupal/Drush bootstrap
# -------------------------------------------------------------------
RUN docker-php-ext-install -j"$(nproc)" pdo_mysql
# -------------------------------------------------------------------
# Install Composer
# -------------------------------------------------------------------
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php --install-dir=/usr/local/bin --filename=composer \
&& rm composer-setup.php
ENV COMPOSER_ALLOW_SUPERUSER=1
# -------------------------------------------------------------------
# Build Drupal project with Drush + OpenID Connect
# IMPORTANT:
# - The Drupal base image uses /var/www/html as a symlink to {{ DRUPAL_DOCKER_HTML_PATH }}
# - Therefore, the actual project root must be placed in /opt/drupal
# -------------------------------------------------------------------
RUN set -eux; \
builddir="$(mktemp -d)"; \
composer create-project --no-interaction --no-ansi --no-progress drupal/recommended-project:^10 "$builddir"; \
composer --working-dir="$builddir" require -n drush/drush:^13 drupal/openid_connect:^2@beta; \
rm -rf /opt/drupal/* /opt/drupal/.[!.]* /opt/drupal/..?* 2>/dev/null || true; \
mkdir -p /opt/drupal; \
cp -a "$builddir"/. /opt/drupal/; \
rm -rf "$builddir"
# -------------------------------------------------------------------
# Make vendor binaries available in PATH
# -------------------------------------------------------------------
RUN ln -sf /opt/drupal/vendor/bin/drush /usr/local/bin/drush
# -------------------------------------------------------------------
# PHP upload configuration
# -------------------------------------------------------------------
COPY {{ DRUPAL_CONFIG_UPLOAD_REL }} $PHP_INI_DIR/conf.d/
# -------------------------------------------------------------------
# Permissions and ownership fixes
# -------------------------------------------------------------------
RUN set -eux; \
# Ensure all directories are traversable
chmod 755 /var /var/www /opt /opt/drupal; \
# Ensure correct ownership for Drupal files
chown -R www-data:www-data /opt/drupal; \
# Apply default permissions
find /opt/drupal -type d -exec chmod 755 {} +; \
find /opt/drupal -type f -exec chmod 644 {} +; \
# Ensure vendor binaries are executable
if [ -d /opt/drupal/vendor/bin ]; then chmod a+rx /opt/drupal/vendor/bin/*; fi; \
if [ -f /opt/drupal/vendor/drush/drush/drush ]; then chmod a+rx /opt/drupal/vendor/drush/drush/drush; fi; \
# Ensure the docroot ({{ DRUPAL_DOCKER_HTML_PATH }}) is accessible
if [ -d {{ DRUPAL_DOCKER_HTML_PATH }} ]; then \
chmod 755 {{ DRUPAL_DOCKER_HTML_PATH }}; \
find {{ DRUPAL_DOCKER_HTML_PATH }} -type d -exec chmod 755 {} +; \
fi; \
# Ensure settings.local.php exists and is owned by www-data
install -o www-data -g www-data -m 640 /dev/null {{ DRUPAL_DOCKER_HTML_PATH }}/sites/default/settings.local.php
# -------------------------------------------------------------------
# Runtime defaults
# -------------------------------------------------------------------
USER www-data
WORKDIR /var/www/html # symlink pointing to {{ DRUPAL_DOCKER_HTML_PATH }}
# Ensure PATH for non-login shells includes /usr/local/bin
ENV PATH="/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"
# -------------------------------------------------------------------
# Build-time check (optional)
# -------------------------------------------------------------------
RUN /usr/local/bin/drush --version

View File

@@ -1,22 +0,0 @@
{% include 'roles/docker-compose/templates/base.yml.j2' %}
application:
{% include 'roles/docker-container/templates/base.yml.j2' %}
image: {{ DRUPAL_CUSTOM_IMAGE }}
container_name: {{ DRUPAL_CONTAINER }}
{{ lookup('template', 'roles/docker-container/templates/build.yml.j2') | indent(4) }}
ports:
- "127.0.0.1:{{ ports.localhost.http[application_id] }}:80"
volumes:
- data:{{ DRUPAL_DOCKER_HTML_PATH }}/sites/default/files
- {{ DRUPAL_MSMTP_ABS }}:/etc/msmtprc
- {{ DRUPAL_SETTINGS_LOCAL_ABS }}:{{ DRUPAL_DOCKER_CONF_PATH }}/settings.local.php
{% include 'roles/docker-container/templates/healthcheck/msmtp_curl.yml.j2' %}
{% include 'roles/docker-container/templates/depends_on/dmbs_excl.yml.j2' %}
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{% include 'roles/docker-compose/templates/networks.yml.j2' %}
{% include 'roles/docker-compose/templates/volumes.yml.j2' %}
data:
name: "{{ DRUPAL_VOLUME }}"

View File

@@ -1,7 +0,0 @@
DRUPAL_DB_HOST= "{{ database_host }}:{{ database_port }}"
DRUPAL_DB_USER= "{{ database_username }}"
DRUPAL_DB_PASSWORD= "{{ database_password }}"
DRUPAL_DB_NAME= "{{ database_name }}"
# Debug flags (optional)
DRUPAL_DEBUG={{ MODE_DEBUG | lower }}

View File

@@ -1,49 +0,0 @@
<?php
/**
* Local settings overrides generated by Ansible.
* - Reads DB + OIDC endpoints from environment variables.
* - Sets $databases and selected $config overrides.
*/
$env = getenv();
/** Database **/
$host = getenv('DRUPAL_DB_HOST') ?: '{{ database_host }}:{{ database_port }}';
$db = getenv('DRUPAL_DB_NAME') ?: '{{ database_name }}';
$user = getenv('DRUPAL_DB_USER') ?: '{{ database_username }}';
$pass = getenv('DRUPAL_DB_PASSWORD') ?: '{{ database_password }}';
$parts = explode(':', $host, 2);
$hostname = $parts[0];
$port = isset($parts[1]) ? (int)$parts[1] : 3306;
$databases['default']['default'] = [
'database' => $db,
'username' => $user,
'password' => $pass,
'prefix' => '',
'host' => $hostname,
'port' => $port,
'namespace'=> 'Drupal\\Core\\Database\\Driver\\mysql',
'driver' => 'mysql',
];
/** OIDC endpoint hints (optional) — the real config is applied via Drush. */
$config['openid_connect.settings']['automatic_account_creation'] = true;
$config['openid_connect.settings']['always_save_userinfo'] = true;
$config['openid_connect.settings']['link_existing_users'] = true;
/** Trusted host patterns can be extended by Ansible task 04_trusted_hosts.yml */
/** Enable local services YML if present */
$settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.local.yml';
// Reverse proxy optional über ENV setzen (z.B. "10.0.0.0/8, 172.16.0.0/12")
$proxy = getenv('REVERSE_PROXY_ADDRESSES');
if ($proxy) {
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = array_map('trim', explode(',', $proxy));
}
/** Hash salt (from schema/credentials, hashed with SHA-256) */
$settings['hash_salt'] = '{{ applications | get_app_conf(application_id, "credentials.hash_salt", True) }}';

View File

@@ -1,8 +0,0 @@
file_uploads = On
memory_limit = {{ DRUPAL_MAX_UPLOAD_SIZE }}
upload_max_filesize = {{ DRUPAL_MAX_UPLOAD_SIZE }}
post_max_size = {{ DRUPAL_MAX_UPLOAD_SIZE }}
max_execution_time = 300
; Use msmtp as the Mail Transfer Agent
sendmail_path = "/usr/bin/msmtp -t"

View File

@@ -1,4 +0,0 @@
users:
administrator:
username: "administrator"
email: "administrator@{{ PRIMARY_DOMAIN }}"

View File

@@ -1,28 +0,0 @@
# General
application_id: "web-app-drupal"
database_type: "mariadb"
# Drupal
DRUPAL_URL: "{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
DRUPAL_CUSTOM_IMAGE: "drupal_custom"
DRUPAL_DOCKER_HTML_PATH: "/opt/drupal/web"
DRUPAL_DOCKER_CONF_PATH: "{{ DRUPAL_DOCKER_HTML_PATH }}/sites/default"
DRUPAL_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.drupal.version') }}"
DRUPAL_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.drupal.image') }}"
DRUPAL_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.drupal.name') }}"
DRUPAL_VOLUME: "{{ applications | get_app_conf(application_id, 'docker.volumes.data') }}"
DRUPAL_DOMAINS: "{{ applications | get_app_conf(application_id, 'server.domains.canonical') }}"
DRUPAL_USER: "www-data"
DRUPAL_CONFIG_UPLOAD_REL: "config/upload.ini"
DRUPAL_CONFIG_UPLOAD_ABS: "{{ [docker_compose.directories.instance, DRUPAL_CONFIG_UPLOAD_REL] | path_join }}"
DRUPAL_SETTINGS_LOCAL_REL: "config/settings.local.php"
DRUPAL_SETTINGS_LOCAL_ABS: "{{ [docker_compose.directories.instance, DRUPAL_SETTINGS_LOCAL_REL] | path_join }}"
DRUPAL_MSMTP_SRC: "{{ [ playbook_dir, 'roles/sys-svc-msmtp/templates/msmtprc.conf.j2' ] | path_join }}"
DRUPAL_MSMTP_ABS: "{{ [ docker_compose.directories.config, 'msmtprc.conf'] | path_join }}"
DRUPAL_MAX_UPLOAD_SIZE: "{{ applications | get_app_conf(application_id, 'max_upload_size') }}"

View File

@@ -1,34 +0,0 @@
# OIDC configuration for Drupal's OpenID Connect module.
# Global settings for openid_connect.settings
oidc_settings:
automatic_account_creation: true # Auto-create users on first login
always_save_userinfo: true # Store latest userinfo on each login
link_existing_users: true # Match existing users by email
login_display: "button" # 'button' or 'form'
enforced: false # If true, require login for the whole site
# OIDC client entity (e.g., 'keycloak')
oidc_client:
id: "keycloak"
label: "Keycloak"
plugin: "generic" # use the built-in generic OIDC client plugin
settings:
client_id: "{{ OIDC.CLIENT.ID }}"
client_secret: "{{ OIDC.CLIENT.SECRET }}"
authorization_endpoint: "{{ OIDC.CLIENT.AUTHORIZE_URL }}"
token_endpoint: "{{ OIDC.CLIENT.TOKEN_URL }}"
userinfo_endpoint: "{{ OIDC.CLIENT.USER_INFO_URL }}"
end_session_endpoint: "{{ OIDC.CLIENT.LOGOUT_URL }}"
scopes:
- "openid"
- "email"
- "profile"
use_standard_claims: true
# Optional claim mapping examples:
# username_claim: "{{ OIDC.ATTRIBUTES.USERNAME }}"
# email_claim: "{{ OIDC.ATTRIBUTES.EMAIL }}"
# given_name_claim: "{{ OIDC.ATTRIBUTES.GIVEN_NAME }}"
# family_name_claim: "{{ OIDC.ATTRIBUTES.FAMILY_NAME }}"

View File

@@ -0,0 +1,34 @@
# Shopware
## Description
Empower your e-commerce vision with **Shopware 6**, a modern, flexible, and open-source commerce platform built on **Symfony and Vue.js**. Designed for growth and innovation, it enables seamless integration, outstanding customer experiences, and complete control over your digital business. Build, scale, and sell with confidence.
## Overview
This role deploys **Shopware 6** using **Docker**. It automates installation, migration, and configuration of your storefront, integrating with a central **MariaDB** database.
Optional components like **Redis** and **OpenSearch** enhance performance and search capabilities, while **OIDC** and **LDAP** support integration with centralized identity systems such as **Keycloak**.
With automated setup, update handling, variable management, and plugin-based authentication, this role simplifies the deployment and maintenance of your Shopware instance.
## Features
* **Modern and Scalable:** A robust Symfony-based framework optimized for commerce innovation.
* **Automated Setup & Maintenance:** Installs, migrates, and configures Shopware automatically.
* **Extensible Architecture:** Optional Redis, OpenSearch, and plugin-based IAM integrations.
* **Centralized Database Access:** Connects seamlessly to the shared MariaDB service.
* **Integrated Configuration:** Environment and Docker Compose variables managed automatically.
## Further Resources
* [Shopware Official Website](https://www.shopware.com/en/)
* [Shopware Developer Documentation](https://developer.shopware.com/)
* [Shopware Store (Plugins)](https://store.shopware.com/en/)
## Credits
Developed and maintained by **Kevin Veen-Birkenbach**.
Learn more at [veen.world](https://www.veen.world).
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code)
Licensed under [Infinito.Nexus NonCommercial License](https://s.infinito.nexus/license).

View File

@@ -0,0 +1,3 @@
# to-dos
- Implement OIDC
- Implement LDAP

View File

@@ -0,0 +1,75 @@
title: "{{ SOFTWARE_NAME }} Shop"
features:
central_database: true
redis: true
ldap: false # Not implemented yet
oidc: false # Not implemented yet
logout: true
server:
csp:
flags:
script-src-elem:
unsafe-inline: true
whitelist: {}
domains:
aliases: []
canonical:
- shop.{{ PRIMARY_DOMAIN }}
docker:
services:
database:
enabled: true
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"
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: 512m
mem_limit: 1g
redis:
enabled: true
image: "redis"
version: "7-alpine"
cpus: 0.25
mem_reservation: 256m
mem_limit: 512m
opensearch:
enabled: true
image: "opensearchproject/opensearch"
version: "2.12.0"
name: "shopware-opensearch"
cpus: 1.0
mem_reservation: 2g
mem_limit: 4g
volumes:
data: "shopware_data"

View File

@@ -0,0 +1,81 @@
#!/bin/sh
set -eu
# Paths / constants
APP_ROOT="/var/www/html"
MARKER="$APP_ROOT/.infinito/installed"
cd "$APP_ROOT"
mkdir -p "$APP_ROOT/.infinito"
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] Checking if database is empty..."
# PHP exits: 0 = empty, 100 = non-empty, 1 = error
if php -r '
$url = getenv("DATABASE_URL");
$p = parse_url($url);
$db = ltrim($p["path"] ?? "", "/");
$dsn = "mysql:host=".($p["host"]??"localhost").";port=".($p["port"]??3306).";dbname=".$db.";charset=utf8mb4";
try {
$pdo = new PDO($dsn, $p["user"] ?? "", $p["pass"] ?? "");
$q = $pdo->query("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=".$pdo->quote($db));
$cnt = (int)$q->fetchColumn();
if ($cnt === 0) { exit(0); } else { exit(100); }
} catch (Exception $e) { fwrite(STDERR, $e->getMessage()."\n"); exit(1); }
'; then
DBCHK=0
else
DBCHK=$?
fi
if [ "$DBCHK" -eq 0 ]; then
echo "[INIT] Installing Shopware (empty DB detected)..."
# IMPORTANT: no --force; let Shopware run its internal steps only on empty DB
php -d memory_limit=1024M bin/console system:install --basic-setup --create-database
elif [ "$DBCHK" -eq 100 ]; then
echo "[INIT] Database is not empty -> skipping system:install"
else
echo "[INIT] Database check failed (code $DBCHK)"; exit 1
fi
# Safe to run (no-ops when up-to-date)
php -d memory_limit=1024M bin/console database:migrate --all || true
php -d memory_limit=1024M bin/console database:migrate-destructive --all || true
# Housekeeping
php bin/console cache:clear || true
php bin/console dal:refresh:index || true
# Marker + perms
touch "$MARKER"
chown -R www-data:www-data "$APP_ROOT"
echo "[INIT] Done."
else
echo "[INIT] Marker found, skipping install."
fi

View File

@@ -0,0 +1,22 @@
---
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Shopware is a modern open-source eCommerce platform built on PHP and Symfony. It enables businesses to create scalable online stores with flexible product management, intuitive administration, customizable storefronts, and powerful APIs for headless and omnichannel commerce."
license: "Infinito.Nexus NonCommercial License"
license_url: "https://s.infinito.nexus/license"
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
galaxy_tags:
- shopware
- ecommerce
repository: https://s.infinito.nexus/code
issue_tracker_url: https://s.infinito.nexus/issues
documentation: "https://docs.infinito.nexus/"
logo:
class: "fa-solid fa-cart-shopping"
run_after:
- web-app-keycloak
- web-app-mailu
dependencies: []

View File

@@ -0,0 +1,2 @@
# Minimal schema placeholder (extend with your own config contract if desired)
credentials: {}

View File

@@ -0,0 +1,19 @@
# Ensures that the admin user exists and always has the desired password
- name: "Ensure Shopware admin exists and has the desired password"
shell: |
docker exec -i --user {{ SHOPWARE_USER }} {{ SHOPWARE_WEB_CONTAINER }} sh -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console user:create "{{ users.administrator.username }}" \
--admin \
--password="{{ users.administrator.password }}" \
--firstName="{{ users.administrator.username }}" \
--lastName="{{ PRIMARY_DOMAIN | lower }}" \
--email="{{ users.administrator.email }}" || true
php bin/console user:change-password "{{ users.administrator.username }}" \
--password="{{ users.administrator.password }}" || true
php bin/console user:update "{{ users.administrator.username }}" \
--email="{{ users.administrator.email }}" 2>/dev/null || true
'
args:
chdir: "{{ docker_compose.directories.instance }}"

View File

@@ -0,0 +1,7 @@
- name: Install & configure OIDC plugin (if enabled)
include_tasks: setup/oidc.yml
when: applications | get_app_conf(application_id, 'features.oidc')
- name: Install & configure LDAP plugin (if enabled)
include_tasks: setup/ldap.yml
when: applications | get_app_conf(application_id, 'features.ldap')

View File

@@ -0,0 +1,7 @@
- name: Remove OIDC plugin if disabled
include_tasks: cleanup/oidc.yml
when: not (applications | get_app_conf(application_id, 'features.oidc'))
- name: Remove LDAP plugin if disabled
include_tasks: cleanup/ldap.yml
when: not (applications | get_app_conf(application_id, 'features.ldap'))

View File

@@ -0,0 +1,10 @@
- name: "Deactivate/uninstall LDAP plugin if present"
shell: |
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
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"

View File

@@ -0,0 +1,10 @@
- name: "Deactivate/uninstall OIDC plugin if present"
shell: |
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
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"

View File

@@ -0,0 +1,35 @@
---
- 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 }}"
copy:
src: init.sh
dest: "{{ SHOPWARE_INIT_HOST }}"
mode: "0755"
notify:
- docker compose up
- docker compose build
- name: "Flush docker compose handlers"
meta: flush_handlers
- name: Wait for Shopware HTTP endpoint
wait_for:
host: "127.0.0.1"
port: "{{ ports.localhost.http[application_id] }}"
delay: 5
timeout: 300
- name: "Ensure admin user exists with correct password"
include_tasks: 01_admin.yml
#- name: Execute setup routines (OIDC/LDAP)
# include_tasks: 02_setup.yml
#
#- name: Execute cleanup routines
# include_tasks: 03_cleanup.yml
# when: MODE_CLEANUP

View File

@@ -0,0 +1,27 @@
# 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_WEB_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console plugin:refresh
php bin/console plugin:install --activate INFX_LDAP_PLUGIN || true
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"
- name: "Configure LDAP connection"
shell: |
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 }}"
php bin/console system:config:set "InfxLdap.config.port" "{{ LDAP.SERVER.PORT }}"
php bin/console system:config:set "InfxLdap.config.bindDn" "{{ LDAP.DN.ADMINISTRATOR.DATA }}"
php bin/console system:config:set "InfxLdap.config.password" "{{ LDAP.BIND_CREDENTIAL }}"
php bin/console system:config:set "InfxLdap.config.userBase" "{{ LDAP.DN.OU.USERS }}"
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"

View File

@@ -0,0 +1,26 @@
# 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_WEB_CONTAINER }} bash -lc '
set -e
cd {{ SHOPWARE_ROOT }}
php bin/console plugin:refresh
php bin/console plugin:install --activate INFX_OIDC_PLUGIN || true
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"
- name: "Configure OIDC via system:config"
shell: |
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 }}"
php bin/console system:config:set "InfxOidc.config.clientSecret" "{{ OIDC.CLIENT.SECRET }}"
php bin/console system:config:set "InfxOidc.config.discoveryUrl" "{{ OIDC.CLIENT.DISCOVERY_DOCUMENT }}"
php bin/console system:config:set "InfxOidc.config.scopes" "openid profile email"
php bin/console cache:clear
'
args:
chdir: "{{ docker_compose.directories.instance }}"

View File

@@ -0,0 +1,92 @@
# ------------------------------------------------------------------------------
# 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 project without installing dependencies
RUN set -eux; \
composer create-project "${SHOPWARE_PROD_VERSION}" /app --no-install
# 2) Install dependencies (ignoring build-time extension checks) + add Redis transport
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; \
composer require symfony/redis-messenger:^6.4 \
-W \
--no-scripts \
--no-progress \
--update-no-dev \
--ignore-platform-req=ext-gd \
--ignore-platform-req=ext-intl \
--ignore-platform-req=ext-pdo_mysql \
--ignore-platform-req=ext-redis
############################
# 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; \
apk add --no-cache php83-redis || apk add --no-cache php82-redis || apk add --no-cache php-redis || 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
# Drop back to the app user
USER www-data
# 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

@@ -0,0 +1,117 @@
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
- "{{ SHOPWARE_INIT_HOST }}:{{ SHOPWARE_INIT_DOCKER }}:ro"
working_dir: {{ SHOPWARE_ROOT }}
{% include 'roles/docker-compose/templates/base.yml.j2' %}
# -------------------------
# INIT (runs once per deployment)
# -------------------------
{% set service_name = 'init' %}
{% set docker_restart_policy = 'no' %}
{{ service_name }}:
{% include 'roles/docker-container/templates/base.yml.j2' %}
{% set docker_restart_policy = DOCKER_RESTART_POLICY %}
<<: *shopware
container_name: "{{ SHOPWARE_INIT_CONTAINER }}"
entrypoint: [ "sh", "{{ SHOPWARE_INIT_DOCKER }}" ]
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{# -------------------------
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' %}
<<: *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 }}"
depends_on:
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 }}:
{% include 'roles/docker-container/templates/base.yml.j2' %}
image: "{{ SHOPWARE_OPENSEARCH_IMAGE }}:{{ SHOPWARE_OPENSEARCH_VERSION }}"
container_name: "{{ SHOPWARE_OPENSEARCH_CONTAINER }}"
environment:
- discovery.type=single-node
- plugins.security.disabled=true
- bootstrap.memory_lock=true
- 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

@@ -0,0 +1,33 @@
# DOMAIN/URL
DOMAIN={{ domains | get_domain(application_id) }}
APP_URL="{{ domains | get_url(application_id, WEB_PROTOCOL) }}"
APP_DEBUG="{{ MODE_DEBUG | ternary(1, 0) }}"
# Shopware
APP_ENV={{ 'dev' if (ENVIRONMENT | lower) == 'development' else 'prod' }}
#TRUSTED_PROXIES=127.0.0.1
INSTANCE_ID={{ application_id }}
# Database
DATABASE_URL="mysql://{{ database_username }}:{{ database_password }}@{{ database_host }}:{{ database_port }}/{{ database_name }}"
# Redis (optional)
{% if SHOPWARE_REDIS_ENABLED | bool %}
REDIS_URL="redis://{{ SHOPWARE_REDIS_ADDRESS }}/0"
CACHE_URL="redis://{{ SHOPWARE_REDIS_ADDRESS }}/1"
MESSENGER_TRANSPORT_DSN="redis://{{ SHOPWARE_REDIS_ADDRESS }}/2"
{% else %}
CACHE_URL="file://cache"
{% endif %}
{% if SHOPWARE_OPENSEARCH_ENABLED %}
# Search
ELASTICSEARCH_URL="http://opensearch:9200"
OPENSEARCH_URL="http://opensearch:9200"
OPENSEARCH_HOST="opensearch"
OPENSEARCH_PORT_NUMBER="9200"
OPENSEARCH_INITIAL_ADMIN_PASSWORD="{{ users.administrator.password }}"
{% endif %}
# Mail (Mailu)
MAILER_DSN="smtps://{{ users['no-reply'].email }}:{{ users['no-reply'].mailu_token }}@{{ SYSTEM_EMAIL.HOST }}:{{ SYSTEM_EMAIL.PORT }}"

View File

@@ -0,0 +1,43 @@
# 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.web.port') }}"
docker_compose_flush_handlers: true
# Shopware container/image vars
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"
SHOPWARE_OPENSEARCH_ENABLED: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.enabled') }}"
SHOPWARE_OPENSEARCH_ENGINE: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.engine') }}"
SHOPWARE_OPENSEARCH_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.image') }}"
SHOPWARE_OPENSEARCH_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.version') }}"
SHOPWARE_OPENSEARCH_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.name') }}"
SHOPWARE_OPENSEARCH_MEM_RESERVATION: "{{ applications | get_app_conf(application_id, 'docker.services.opensearch.mem_reservation') }}"
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') }}"

View File

@@ -88,16 +88,15 @@
taiga:
{% set service_name = TAIGA_FRONT_SERVICE %}
{% set container_port = 80 %}
{{ service_name }}:
container_name: {{ TAIGA_CONTAINER }}-{{ service_name }}
image: "{{TAIGA_DOCKER_IMAGE_FRONTEND}}:{{ TAIGA_VERSION }}"
{% include 'roles/docker-container/templates/base.yml.j2' %}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || curl -fsS http://127.0.0.1/ >/dev/null"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
{% filter indent(4) %}
{% include 'roles/docker-container/templates/healthcheck/http.yml.j2' %}
{% endfilter %}
{% include 'roles/docker-container/templates/networks.yml.j2' %}
taiga:
# volumes:
@@ -152,22 +151,21 @@
taiga:
{% set service_name = 'gateway' %}
{% set container_port = 80 %}
{{ service_name }}:
container_name: {{ TAIGA_CONTAINER }}-{{ service_name }}
image: nginx:alpine
ports:
- "127.0.0.1:{{ ports.localhost.http[application_id] }}:80"
- "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}"
volumes:
- {{ docker_repository_path }}taiga-gateway/taiga.conf:/etc/nginx/conf.d/default.conf
- static-data:/taiga/static
- media-data:/taiga/media
{% include 'roles/docker-container/templates/base.yml.j2' %}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || curl -fsS http://127.0.0.1/ >/dev/null"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
{% filter indent(4) %}
{% include 'roles/docker-container/templates/healthcheck/http.yml.j2' %}
{% endfilter %}
{% include 'roles/docker-container/templates/networks.yml.j2' %}
taiga:
depends_on:

View File

View File

@@ -0,0 +1,117 @@
import os
import sys
import unittest
from importlib import import_module
# Compute repo root (…/tests/unit/roles/web-app-desktop/lookup_plugins/docker_cards_grouped.py -> repo root)
_THIS_DIR = os.path.dirname(__file__)
_REPO_ROOT = os.path.abspath(os.path.join(_THIS_DIR, "../../../../.."))
# Add the lookup_plugins directory to sys.path so we can import the plugin as a plain module
_LOOKUP_DIR = os.path.join(_REPO_ROOT, "roles", "web-app-desktop", "lookup_plugins")
if _LOOKUP_DIR not in sys.path:
sys.path.insert(0, _LOOKUP_DIR)
# Import the plugin module
plugin = import_module("docker_cards_grouped")
LookupModule = plugin.LookupModule
try:
from ansible.errors import AnsibleError
except Exception: # Fallback for environments without full Ansible
class AnsibleError(Exception):
pass
class TestDockerCardsGroupedLookup(unittest.TestCase):
def setUp(self):
self.lookup = LookupModule()
# Menu categories with mixed-case names to verify case-insensitive sort
self.menu_categories = {
"B-Group": {"tags": ["b", "beta"]},
"a-Group": {"tags": ["a", "alpha"]},
"Zeta": {"tags": ["z"]},
}
# Cards with tags; one should end up uncategorized
self.cards = [
{"title": "Alpha Tool", "tags": ["a"]},
{"title": "Beta Widget", "tags": ["beta"]},
{"title": "Zed App", "tags": ["z"]},
{"title": "Unmatched Thing", "tags": ["x"]},
]
def _run(self, cards=None, menu_categories=None):
result = self.lookup.run(
[cards or self.cards, menu_categories or self.menu_categories]
)
# Plugin returns a single-element list containing the result dict
self.assertIsInstance(result, list)
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], dict)
return result[0]
def test_categorization_and_uncategorized(self):
data = self._run()
self.assertIn("categorized", data)
self.assertIn("uncategorized", data)
categorized = data["categorized"]
uncategorized = data["uncategorized"]
# Each matching card is placed into the proper category
self.assertIn("a-Group", categorized)
self.assertIn("B-Group", categorized)
self.assertIn("Zeta", categorized)
titles_in_a = [c["title"] for c in categorized["a-Group"]]
titles_in_b = [c["title"] for c in categorized["B-Group"]]
titles_in_z = [c["title"] for c in categorized["Zeta"]]
self.assertEqual(titles_in_a, ["Alpha Tool"])
self.assertEqual(titles_in_b, ["Beta Widget"])
self.assertEqual(titles_in_z, ["Zed App"])
# Unmatched card should be in 'uncategorized'
self.assertEqual(len(uncategorized), 1)
self.assertEqual(uncategorized[0]["title"], "Unmatched Thing")
def test_categories_sorted_alphabetically_case_insensitive(self):
data = self._run()
categorized = data["categorized"]
# Verify order is alphabetical by key, case-insensitive
keys = list(categorized.keys())
self.assertEqual(keys, ["a-Group", "B-Group", "Zeta"])
def test_multiple_tags_match_first_category_encountered(self):
# A card that matches multiple categories should be placed
# into the first matching category based on menu_categories iteration order.
# Here "Dual Match" has both 'a' and 'b' tags; since "a-Group" is alphabetically
# before "B-Group" only after sorting happens at RETURN time, we need to ensure the
# assignment is based on menu_categories order (insertion order).
menu_categories = {
"B-Group": {"tags": ["b"]},
"a-Group": {"tags": ["a"]},
}
cards = [{"title": "Dual Match", "tags": ["a", "b"]}]
# The plugin iterates menu_categories in insertion order and breaks on first match,
# so this card should end up in "B-Group".
data = self._run(cards=cards, menu_categories=menu_categories)
categorized = data["categorized"]
self.assertIn("B-Group", categorized)
self.assertEqual([c["title"] for c in categorized["B-Group"]], ["Dual Match"])
self.assertNotIn("a-Group", categorized) # no card added there
def test_missing_arguments_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run([]) # no args
with self.assertRaises(AnsibleError):
self.lookup.run([[]]) # only one arg
if __name__ == "__main__":
unittest.main()