diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000000000000000000000000000000000000..3d1de41d12aee5ed3ea75edb95367bb33211c4f5 --- /dev/null +++ b/docker/.env @@ -0,0 +1,16 @@ +# ====================================================================== +# +# GENERATED BY ./init.sh - template: ./templates/dot_env - e2cde05722688ff85d3a93e9cd55787e +# values to be used in docker-composer.yml +# +# ====================================================================== + +# ----- application +APP_NAME=imlcinode + +# uid of www-data in the docker container +DOCKER_USER_UID=33 + +APP_PORT=8002 +WEBROOT=/var/www/imlcinode/public_html + diff --git a/docker/containers/web-server/Dockerfile b/docker/containers/web-server/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a4e71e7c4fdc6a5e5a398579dd6e890efb8cdbe1 --- /dev/null +++ b/docker/containers/web-server/Dockerfile @@ -0,0 +1,14 @@ +# +# GENERATED BY ./init.sh - template: ./templates/web-server-Dockerfile - 42dce773c83597a7d05af398bdd66d15 +# +FROM php:8.1-apache + +# install packages +RUN apt-get update && apt-get install -y git unzip zip + +# enable apache modules +RUN a2enmod rewrite + +# install php packages +COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ +RUN install-php-extensions curl mbstring ldap intl xml diff --git a/docker/containers/web-server/apache/sites-enabled/vhost_app.conf b/docker/containers/web-server/apache/sites-enabled/vhost_app.conf new file mode 100644 index 0000000000000000000000000000000000000000..bb0470db592ad9e0e110f89a4ba21ff42a2c9b5a --- /dev/null +++ b/docker/containers/web-server/apache/sites-enabled/vhost_app.conf @@ -0,0 +1,36 @@ +# +# GENERATED BY ./init.sh - template: ./templates/vhost_app.conf - 9a9cf79de5a3584c0cef6cb79c339c25 +# + + +# load modules +# LoadModule rewrite_module /usr/lib/apache2/modules/mod_rewrite.so + + +<VirtualHost *:80> + DocumentRoot /var/www/imlcinode/public_html + + <Directory "/var/www/imlcinode/public_html/deployment/"> + + # AuthUserFile /var/www/imlcinode/public_html/deployment/.htusers + # AuthName "IML DEPLOYMENT GUI" + # AuthType Basic + + # #Allow any valid user + # require valid-user + + RewriteEngine on + RewriteCond %{REQUEST_FILENAME} !-f + # not needed in a subdir + # RewriteCond %{REQUEST_URI} !^/server-status$ + RewriteRule ^(.*)$ index.php [QSA,L] + + </Directory> + + <Directory "/var/www/imlcinode/public_html/api/"> + RewriteEngine on + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^(.*)$ index.php [QSA,L] + </Directory> + +</VirtualHost> \ No newline at end of file diff --git a/docker/containers/web-server/php/extra-php-config.ini b/docker/containers/web-server/php/extra-php-config.ini new file mode 100644 index 0000000000000000000000000000000000000000..44f13ba321f5a82594326077a3d7cc89caa3478e --- /dev/null +++ b/docker/containers/web-server/php/extra-php-config.ini @@ -0,0 +1,23 @@ +; +; GENERATED BY ./init.sh - template: ./templates/extra-php-config.ini - 80c23edaf568e2c36b9926fe2339e481 +; +[PHP] + +error_reporting=E_ALL +display_errors=1 + +; ---------------------------------------------------------------------- +; XDEBUG STUFF BELOW +; ---------------------------------------------------------------------- +; +; +; [xdebug] +; xdebug.mode=develop,debug +; ; xdebug.client_host=localhost +; xdebug.start_with_request=yes +; ; xdebug.start_with_request=trigger +; +; xdebug.log=/tmp/xdebug.log +; xdebug.discover_client_host = 1 +; ; xdebug.client_port=9003 +; xdebug.idekey="netbeans-xdebug" \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..797734a417291b696729ddfd17973cdffa0ce23a --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,51 @@ +# +# GENERATED BY ./init.sh - template: ./templates/docker-compose.yml - 482a0fd9745c482f4cc4b3871c3fa4b3 +# +# ====================================================================== +# +# (1) see .env for set variables +# (2) run "docker-compose up" to startup +# +# ====================================================================== +version: '3.9' + +networks: + imlcinode-network: + +services: + + # ----- apache httpd + php + imlcinode-web-server: + + build: + context: . + dockerfile: ./containers/web-server/Dockerfile + + # keep "FROM" in docker file ... then image is not needed here + # image: "php:8.1-apache" + + container_name: 'imlcinode-server' + ports: + - '${APP_PORT}:80' + + working_dir: ${WEBROOT} + + volumes: + - ../:/var/www/${APP_NAME} + - ../data/imldeployment:/var/imldeployment + - ../data/tmp:/var/tmp/imldeployment + - ./containers/web-server/apache/sites-enabled:/etc/apache2/sites-enabled + - ./containers/web-server/php/extra-php-config.ini:/usr/local/etc/php/conf.d/extra-php-config.ini + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost"] + interval: 10s + timeout: 3s + retries: 5 + # start_period: 40s + + networks: + - imlcinode-network + + user: ${DOCKER_USER_UID} + diff --git a/docker/init.sh b/docker/init.sh new file mode 100644 index 0000000000000000000000000000000000000000..d89fe91c7af63f5094c1a96122579e0682d125a2 --- /dev/null +++ b/docker/init.sh @@ -0,0 +1,315 @@ +#!/bin/bash +# ====================================================================== +# +# DOCKER PHP DEV ENVIRONMENT :: INIT +# (work in progress) +# +# ---------------------------------------------------------------------- +# 2021-11-nn <axel.hahn@iml.unibe.ch> +# ====================================================================== + +cd $( dirname $0 ) +. $( basename $0 ).cfg + +# git@git-repo.iml.unibe.ch:iml-open-source/docker-php-starterkit.git +selfgitrepo="docker-php-starterkit.git" + +# ---------------------------------------------------------------------- +# FUNCTIONS +# ---------------------------------------------------------------------- + +# draw a headline 2 +function h2(){ + echo + echo -e "\e[33m>>>>> $*\e[0m" +} + +# draw a headline 3 +function h3(){ + echo + echo -e "\e[34m----- $*\e[0m" +} + +# function _gitinstall(){ +# h2 "install/ update app from git repo ${gitrepo} in ${gittarget} ..." +# test -d ${gittarget} && ( cd ${gittarget} && git pull ) +# test -d ${gittarget} || git clone -b ${gitbranch} ${gitrepo} ${gittarget} +# } + +# set acl on local directory +function _setWritepermissions(){ + h2 "set write permissions on ${gittarget} ..." + + local _user=$( id -gn ) + typeset -i local _user_uid=0 + test -f /etc/subuid && _user_uid=$( grep $_user /etc/subuid 2>/dev/null | cut -f 2 -d ':' )-1 + typeset -i local DOCKER_USER_OUTSIDE=$_user_uid+$DOCKER_USER_UID + + set -vx + + for mywritedir in ${WRITABLEDIR} + do + + echo "--- ${mywritedir}" + # remove current acl + sudo setfacl -bR "${mywritedir}" + + # default permissions: both the host user and the user with UID 33 (www-data on many systems) are owners with rwx perms + sudo setfacl -dRm u:${DOCKER_USER_OUTSIDE}:rwx,${_user}:rwx "${mywritedir}" + + # permissions: make both the host user and the user with UID 33 owner with rwx perms for all existing files/directories + sudo setfacl -Rm u:${DOCKER_USER_OUTSIDE}:rwx,${_user}:rwx "${mywritedir}" + done + + set +vx +} + +# cleanup starterkit git data +function _removeGitdata(){ + h2 "Remove git data of starterkit" + echo -n "Current git remote url: " + git config --get remote.origin.url + git config --get remote.origin.url 2>/dev/null | grep $selfgitrepo >/dev/null + if [ $? -eq 0 ]; then + echo + echo -n "Delete local .git and .gitignore? [y/N] > " + read answer + test "$answer" = "y" && ( echo "Deleting ... " && rm -rf ../.git ../.gitignore ) + else + echo "It was done already - $selfgitrepo was not found." + fi + +} + +# helper function: cut a text file starting from database start marker +# see _generateFiles() +function _fix_no-db(){ + local _file=$1 + if [ $DB_ADD = false ]; then + typeset -i local iStart=$( cat ${_file} | fgrep -n "$CUTTER_NO_DATABASE" | cut -f 1 -d ':' )-1 + if [ $iStart -gt 0 ]; then + sed -ni "1,${iStart}p" ${_file} + fi + fi +} + +# loop over all files in templates subdir make replacements and generate +# a target file. +# It skips if +# - 1st line is not starting with "# TARGET: filename" +# - target file has no updated lines +function _generateFiles(){ + + # re-read config vars + . $( basename $0 ).cfg + + local _tmpfile=/tmp/newfilecontent$$.tmp + h2 "generate files from templates..." + for mytpl in $( ls -1 ./templates/* ) + do + # h3 $mytpl + local _doReplace=1 + + # fetch traget file from first line + target=$( head -1 $mytpl | grep "^# TARGET:" | cut -f 2- -d ":" | awk '{ print $1 }' ) + + if [ -z "$target" ]; then + echo SKIP: $mytpl - target was not found in 1st line + _doReplace=0 + fi + + # write generated files to target + if [ $_doReplace -eq 1 ]; then + + # write file from line 2 to a tmp file + sed -n '2,$p' $mytpl >$_tmpfile + + # add generator + # sed -i "s#{{generator}}#generated by $0 - template: $mytpl - $( date )#g" $_tmpfile + local _md5=$( md5sum $_tmpfile | awk '{ print $1 }' ) + sed -i "s#{{generator}}#GENERATED BY $0 - template: $mytpl - $_md5#g" $_tmpfile + + # loop over vars to make the replacement + grep "^[a-zA-Z]" $( basename $0 ).cfg | while read line + do + # echo replacement: $line + mykey=$( echo $line | cut -f 1 -d '=' ) + myvalue="$( eval echo \"\${$mykey}\" )" + # grep "{{$mykey}}" $_tmpfile + + # TODO: multiline values fail here in replacement with sed + sed -i "s#{{$mykey}}#${myvalue}#g" $_tmpfile + done + _fix_no-db $_tmpfile + + # echo "changes for $target:" + diff "../$target" "$_tmpfile" | grep -v "$_md5" | grep -v "^---" | grep . + if [ $? -eq 0 -o ! -f "../$target" ]; then + echo -n "$mytpl - changes detected - writing [$target] ... " + mkdir -p $( dirname "../$target" ) || exit 2 + mv "$_tmpfile" "../$target" || exit 2 + echo OK + else + rm -f $_tmpfile + echo "SKIP: $mytpl - Nothing to do." + fi + fi + echo + done + +} + +# loop over all files in templates subdir make replacements and generate +# a traget file. +function _removeGeneratedFiles(){ + h2 "remove generated files..." + for mytpl in $( ls -1 ./templates/* ) + do + h3 $mytpl + + # fetch traget file from first line + target=$( head -1 $mytpl | grep "^# TARGET:" | cut -f 2- -d ":" | awk '{ print $1 }' ) + + if [ ! -z "$target" -a -f "../$target" ]; then + echo -n "REMOVING " + ls -l "../$target" || exit 2 + rm -f "../$target" || exit 2 + echo OK + else + echo SKIP: $target + fi + + done +} + +function _showContainers(){ + local bLong=$1 + h2 CONTAINERS + if [ -z "$bLong" ]; then + docker-compose ps + else + docker ps | grep $APP_NAME + fi +} + + +# a bit stupid ... i think I need to delete it. +function _showInfos(){ + _showContainers long + h2 INFO + + h3 "processes" + docker-compose top + + h3 "Check app port" + >/dev/tcp/localhost/${APP_PORT} 2>/dev/null && ( + echo "OK, app port ${APP_PORT} is reachable" + echo + echo "In a web browser open:" + echo " $frontendurl" + ) + h3 "Check database port" + >/dev/tcp/localhost/${DB_PORT} 2>/dev/null && ( + echo "OK, db port ${DB_PORT} is reachable" + echo + echo "In a local DB admin tool:" + echo " host : localhost" + echo " port : ${DB_PORT}" + echo " user : root" + echo " password: ${MYSQL_ROOT_PASS}" + ) + echo +} + +function _wait(){ + echo -n "... press RETURN > "; read dummy +} + +# ---------------------------------------------------------------------- +# MAIN +# ---------------------------------------------------------------------- + +action=$1 + +while true; do + echo + echo -e "\e[32m===== INITIALIZER FOR APP [$APP_NAME] ===== \e[0m" + + if [ -z "$action" ]; then + + _showContainers + + h2 MENU + echo " g - remove git data of starterkit" + echo + echo " i - init application: set permissions" + echo " t - generate files from templates" + echo " T - remove generated files" + echo + echo " u - startup containers docker-compose up -d" + echo " s - shutdown containers docker-compose stop" + echo " r - remove containers docker-compose rm -f" + echo + echo " m - more infos" + echo " c - console (bash)" + echo + echo -n " select >" + read action + fi + + case "$action" in + g) + _removeGitdata + ;; + i) + # _gitinstall + _setWritepermissions + ;; + t) + _generateFiles + ;; + T) + _removeGeneratedFiles + rm -rf containers + ;; + f) + _removeGeneratedFiles + _generateFiles + _wait + ;; + m) + _showInfos + _wait + ;; + u) + if docker-compose --verbose up -d --remove-orphans; then + # test ! -z "${APP_ONSTARTUP}" && sleep 2 && docker exec -it appmonitor-server /bin/bash -c "${APP_ONSTARTUP}" + echo "In a web browser:" + echo " $frontendurl" + else + echo "ERROR: docker-compose up failed :-/" + docker-compose logs | tail + fi + echo + + _wait + ;; + s) + docker-compose stop + ;; + r) + docker-compose rm -f + ;; + c) + docker ps + echo -n "id or name >" + read dockerid + test -z "$dockerid" || docker exec -it $dockerid /bin/bash + ;; + *) echo "ACTION [$action] NOT IMPLEMENTED." + esac + action= +done + + +# ---------------------------------------------------------------------- diff --git a/docker/init.sh.cfg b/docker/init.sh.cfg new file mode 100644 index 0000000000000000000000000000000000000000..2be3a4dbe45a5097d2cb5ded81fb4d2f444c2fa3 --- /dev/null +++ b/docker/init.sh.cfg @@ -0,0 +1,76 @@ +# ====================================================================== +# +# settings for init.sh and base values for replacements in template files +# This script is sourced by init.sh ... this file is bash syntax +# +# ---------------------------------------------------------------------- +# 2021-11-xx <axel.hahn@iml.unibe.ch> +# ====================================================================== + +APP_NAME=imlcinode + +# web port 80 in container is seen on localhost as ... +APP_PORT=8002 + +# document root inside container +WEBROOT=/var/www/${APP_NAME}/public_html + +APP_APT_PACKAGES="git unzip zip rsync" +# TODO: +# APP_APT_PACKAGES="git unzip zip yui-compressor" +# uglify-js yarn +# libpq-dev openjdk-7-jdk maven zlib1g-dev + +APP_APACHE_MODULES="rewrite" +# APP_APACHE_MODULES="" + +APP_PHP_VERSION=8.1 +# sqlite3 is active already +APP_PHP_MODULES="curl mbstring ldap intl xml" + +# ONSTARTUP="docker exec -it appmonitor-server nohup /usr/local/bin/php /var/www/appmonitor/public_html/server/service.php > /tmp/appmonitor-service.log &" +# APP_ONSTARTUP="php ${WEBROOT}/server/service.php" +# APP_ONSTARTUP="a2enmod rewrite" + + +# ---------------------------------------------------------------------- + +# add a container with database? +DB_ADD=false + +# ---------------------------------------------------------------------- +# for an optional database server + +DB_PORT=13306 + +# ----- database settings +MYSQL_IMAGE=mariadb:10.5.9 +MYSQL_RANDOM_ROOT_PASSWORD=0 +MYSQL_ALLOW_EMPTY_PASSWORD=0 +MYSQL_ROOT_PASS=12345678 +MYSQL_USER=${APP_NAME} +MYSQL_PASS=mypassword +MYSQL_DB=${APP_NAME} + + +# ====================================================================== +# ignore things below + + +# where to set acl where local user and web user in container +# can write simultanously +# divide multiple dirs with space +WRITABLEDIR="../public_html ../config ../data" + + +# web service user in container +DOCKER_USER_UID=33 + +# document root inside web-server container +WEBROOT=/var/www/${APP_NAME}/public_html + +CUTTER_NO_DATABASE="CUT-HERE-FOR-NO-DATABASE" + +frontendurl=http://localhost:${APP_PORT}/ + +# ---------------------------------------------------------------------- diff --git a/docker/templates/docker-compose.yml b/docker/templates/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..c426d167c11d91b213d6e950d5669075234f1948 --- /dev/null +++ b/docker/templates/docker-compose.yml @@ -0,0 +1,78 @@ +# TARGET: docker/docker-compose.yml +# +# {{generator}} +# +# ====================================================================== +# +# (1) see .env for set variables +# (2) run "docker-compose up" to startup +# +# ====================================================================== +version: '3.9' + +networks: + {{APP_NAME}}-network: + +services: + + # ----- apache httpd + php + {{APP_NAME}}-web-server: + + build: + context: . + dockerfile: ./containers/web-server/Dockerfile + + # keep "FROM" in docker file ... then image is not needed here + # image: "php:{{APP_PHP_VERSION}}-apache" + + container_name: '{{APP_NAME}}-server' + ports: + - '${APP_PORT}:80' + + working_dir: ${WEBROOT} + + volumes: + - ../:/var/www/${APP_NAME} + - ../data/imldeployment:/var/imldeployment + - ../data/tmp:/var/tmp/imldeployment + - ./containers/web-server/apache/sites-enabled:/etc/apache2/sites-enabled + - ./containers/web-server/php/extra-php-config.ini:/usr/local/etc/php/conf.d/extra-php-config.ini + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost"] + interval: 10s + timeout: 3s + retries: 5 + # start_period: 40s + + networks: + - {{APP_NAME}}-network + + user: ${DOCKER_USER_UID} + + # --- 8< --- {{CUTTER_NO_DATABASE}} --- 8< --- + + depends_on: + - {{APP_NAME}}-db-server + + # ----- mariadb + {{APP_NAME}}-db-server: + image: {{MYSQL_IMAGE}} + container_name: '${APP_NAME}-db' + # restart: always + ports: + - '${DB_PORT}:3306' + environment: + MYSQL_ROOT_PASSWORD: '${MYSQL_ROOT_PASS}' + MYSQL_USER: '${MYSQL_USER}' + MYSQL_PASSWORD: '${MYSQL_PASS}' + MYSQL_DATABASE: '${MYSQL_DB}' + volumes: + # - ./containers/db-server/db_data:/var/lib/mysql + - ./containers/db-server/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf + healthcheck: + test: mysqladmin ping -h 127.0.0.1 -u root --password=$$MYSQL_ROOT_PASSWORD + interval: 5s + retries: 5 + networks: + - {{APP_NAME}}-network diff --git a/docker/templates/dot_env b/docker/templates/dot_env new file mode 100644 index 0000000000000000000000000000000000000000..bc8af1d5372ca0731b4e2eebd0a3e95cc2d1c78b --- /dev/null +++ b/docker/templates/dot_env @@ -0,0 +1,28 @@ +# TARGET: docker/.env +# ====================================================================== +# +# {{generator}} +# values to be used in docker-composer.yml +# +# ====================================================================== + +# ----- application +APP_NAME={{APP_NAME}} + +# uid of www-data in the docker container +DOCKER_USER_UID={{DOCKER_USER_UID}} + +APP_PORT={{APP_PORT}} +WEBROOT={{WEBROOT}} + +# --- 8< --- {{CUTTER_NO_DATABASE}} --- 8< --- + +DB_PORT={{DB_PORT}} + +# ----- database settings +MYSQL_RANDOM_ROOT_PASSWORD={{MYSQL_RANDOM_ROOT_PASSWORD}} +MYSQL_ALLOW_EMPTY_PASSWORD={{MYSQL_ALLOW_EMPTY_PASSWORD}} +MYSQL_ROOT_PASS={{MYSQL_ROOT_PASS}} +MYSQL_USER={{APP_NAME}} +MYSQL_PASS={{MYSQL_PASS}} +MYSQL_DB={{APP_NAME}} diff --git a/docker/templates/extra-php-config.ini b/docker/templates/extra-php-config.ini new file mode 100644 index 0000000000000000000000000000000000000000..689921c061d9bb2787a44c8e8807ef184016a69d --- /dev/null +++ b/docker/templates/extra-php-config.ini @@ -0,0 +1,24 @@ +# TARGET: docker/containers/web-server/php/extra-php-config.ini +; +; {{generator}} +; +[PHP] + +error_reporting=E_ALL +display_errors=1 + +; ---------------------------------------------------------------------- +; XDEBUG STUFF BELOW +; ---------------------------------------------------------------------- +; +; +; [xdebug] +; xdebug.mode=develop,debug +; ; xdebug.client_host=localhost +; xdebug.start_with_request=yes +; ; xdebug.start_with_request=trigger +; +; xdebug.log=/tmp/xdebug.log +; xdebug.discover_client_host = 1 +; ; xdebug.client_port=9003 +; xdebug.idekey="netbeans-xdebug" \ No newline at end of file diff --git a/docker/templates/readme.md b/docker/templates/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..34c2c57c1ad0518f5cc00d12148cdddc0720187f --- /dev/null +++ b/docker/templates/readme.md @@ -0,0 +1,7 @@ +# Templates + +## Rules + +* in the first line must be a line `# TARGET: [name of target file]` to define the target file +* Placeholdrs have the syntax variable in double brackets, i.e. `{{VARNAME}}` +* variables to be replaced are those in docker/init.sh.cfg and `{{genrator}}` diff --git a/docker/templates/vhost_app.conf b/docker/templates/vhost_app.conf new file mode 100644 index 0000000000000000000000000000000000000000..ea818a94da9d64b026f37f995693a29194d868c9 --- /dev/null +++ b/docker/templates/vhost_app.conf @@ -0,0 +1,37 @@ +# TARGET: docker/containers/web-server/apache/sites-enabled/vhost_app.conf +# +# {{generator}} +# + + +# load modules +# LoadModule rewrite_module /usr/lib/apache2/modules/mod_rewrite.so + + +<VirtualHost *:80> + DocumentRoot {{WEBROOT}} + + <Directory "{{WEBROOT}}/deployment/"> + + # AuthUserFile {{WEBROOT}}/deployment/.htusers + # AuthName "IML DEPLOYMENT GUI" + # AuthType Basic + + # #Allow any valid user + # require valid-user + + RewriteEngine on + RewriteCond %{REQUEST_FILENAME} !-f + # not needed in a subdir + # RewriteCond %{REQUEST_URI} !^/server-status$ + RewriteRule ^(.*)$ index.php [QSA,L] + + </Directory> + + <Directory "{{WEBROOT}}/api/"> + RewriteEngine on + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^(.*)$ index.php [QSA,L] + </Directory> + +</VirtualHost> \ No newline at end of file diff --git a/docker/templates/web-server-Dockerfile b/docker/templates/web-server-Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d2443af6363a7d5ad2194d9c02349fb8fc3e218d --- /dev/null +++ b/docker/templates/web-server-Dockerfile @@ -0,0 +1,15 @@ +# TARGET: docker/containers/web-server/Dockerfile +# +# {{generator}} +# +FROM php:{{APP_PHP_VERSION}}-apache + +# install packages +RUN apt-get update && apt-get install -y {{APP_APT_PACKAGES}} + +# enable apache modules +RUN a2enmod {{APP_APACHE_MODULES}} + +# install php packages +COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ +RUN install-php-extensions {{APP_PHP_MODULES}}