diff --git a/.bu-isciii-deployment/state.json b/.bu-isciii-deployment/state.json new file mode 100644 index 000000000..10abcca62 --- /dev/null +++ b/.bu-isciii-deployment/state.json @@ -0,0 +1,63 @@ +{ + "config": { + "ADDONS": { + "apache": { + "CONFIG_SERVICE": "iskylims" + }, + "samba": { + "CONFIG_SERVICE": "iskylims", + "MODES": [ + "test" + ] + } + }, + "APP_NAME": "iSkyLIMS", + "APP_SLUG": "iskylims", + "DEFAULT_BRANCH": "main", + "DESCRIPTION": "Laboratory information management system for wet-lab and dry-lab sequencing workflows.", + "PYTHON_VERSION": "3.11", + "REPOSITORY_URL": "https://github.com/BU-ISCIII/iSkyLIMS.git", + "SERVICES": { + "iskylims": { + "BUILD_CONTEXT": ".", + "DOCKERFILE": "Dockerfile", + "IMAGE": "iskylims:local", + "INSTALL_CONF": "conf/docker_production_settings.txt", + "PROFILE": "django", + "PROJECT_MODULE": "iskylims", + "TEST_INSTALL_CONF": "conf/docker_test_settings.txt" + } + }, + "TIMEZONE": "Europe/Madrid" + }, + "files": { + ".dockerignore": "bd9c115511d6b9dae318e4b6a85ddb282e60a46fd89c6cdecccdc20c668eb0e9", + ".gitignore": "de487336ed72efd6601b985eda1c83754d6ee33b1cb4ce9f8973411caae833ed", + "Dockerfile": "95d77c59cec771592957755e699c19401f70e631eb42bd6a9ac73c2024786de1", + "LEAME.md": "9c7353110884db0a9e48b92d35c3ed8813b83e331d5ae2ab75a70f2878bd4811", + "README.md": "f8808b6ee80aa7a942dd505463f155b2314bd70f948d74ce6e3f9fad938d8c22", + "conf/INSTALL_SETTINGS.md": "f5b1437a0e13a803ff020b5cb3aa1476b6876c169b23e6bcfc3e5042224af636", + "conf/apache/00-logs.conf": "3c2ab4e693e528d2cad37c0c5bb0bc4511803a7f206b339182de192d4e398092", + "conf/apache/01-reverse-proxy.conf": "1c93e81d44ef88091c4ec7872d9220128813ba5111908ea70b22996182ffd5be", + "conf/apache/02-server-status.conf": "c3b5d26f9b551083faf0d85b188bfa9c577ff0bd3b805f0ec104fe4f7d5101cb", + "conf/apache/apache_production_settings.txt": "169c2d22d4933090003875da7359c49926b9dd4be0838a7875744723c9cf771a", + "conf/apache/apache_test_settings.txt": "8092b49e5b3950a9e6ef64f6fc9d004d51e487cfdd4c17c71bcfa3469494e0b6", + "conf/docker_production_settings.txt": "51182733d5cd3b12d826cd085a804e310c9a89bced71d7396cbef59df2801c82", + "conf/docker_test_settings.txt": "c1d910b42629559304ee6a750d6f37a5ed322ca9efa7a9cfffd373de7a75cae6", + "conf/samba/samba_production_settings.txt": "f19da30ab0890a79254e22c571629b34de2d37ad9c12449607158bff9a9188a1", + "conf/samba/samba_test_settings.txt": "c383b88d43de8d65886cdd2b45bf9f27d91f5f3fe290f8f82378578fbeeff0b2", + "conf/template_settings.py": "af539ad10729a371e0c735c121e6e62dfece96afce77fb86e3a0820df2e9dd2f", + "container_install.sh": "82500f1e800e1a5dbd3429b14f1c3bae7f3554f988408b5f7939478afde45abd", + "deployment_health/README.md": "3d7c4295938708813369deeac9edc0eb8fe2153c77d03f9138febf3aed715aa5", + "deployment_health/__init__.py": "bedcff132309f8970b1ca72fcea20c7cb38bb727d48f82d96dbf7104922abda0", + "deployment_health/urls.py": "8d274b44d326f26d71b05021a34969c742478efbe47691aadd8c3a90ba16f340", + "deployment_health/views.py": "d19cc3e8fef8519bae2b7fbdc25d17f6ae7df02479aa8890f4b4d250bb07f2e4", + "docker-compose.prod.yml": "3408e0e424a06587f61e9e04d46e4225bdfab473206bd28a1f18d2d014b2d9dc", + "docker-compose.test.yml": "a56a89a26ae8e30627e401405e273d99cfeb24b8e508f24204882d57ba79f51d", + "install.sh": "6cedd3a990de21bf2343b75d8f93dd37420c1b358a1529b5e50892cf6df1526c", + "scripts/container_start.sh": "8d6e24f848d0562c17f323bbcf8d49e89a247648895bc209c4eb79bc9c8adf12", + "scripts/smoke_test.sh": "24aabe5c89c2ede2d373d282f1c3d17ed4a19f3c8e73b07a148e8472869bf17f" + }, + "source": "BU-ISCIII deployment standards", + "standard_version": "0.1.0" +} diff --git a/.dockerignore b/.dockerignore index 25b5269f2..f481d8042 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,53 +1,34 @@ -# Keep .git: install/build diagnostics use git metadata inside the image. +# Production installation settings are passed through a build secret. Exclude +# all local settings and allow only committed, non-sensitive templates/tests. +install_settings.txt +conf/*settings*.txt +!conf/docker_test_settings.txt +!conf/template_install_settings.txt +!conf/template_settings.txt +.tmp_docker_install_conf_*.txt +.env +.env.* +!.env.example +.env.prod.file +# Runtime-rendered add-on bind sources are not image build inputs. +deployment/apache/*.conf +deployment/settings/ -# Local Python environments and caches .venv/ venv/ -env/ -ENV/ virtualenv/ +node_modules/ +.npm/ +.next/ +.vite/ __pycache__/ *.py[cod] .pytest_cache/ -.mypy_cache/ -.hypothesis/ -.coverage -.coverage.* -htmlcov/ -.tox/ - -# Local editor and OS files .vscode/ .idea/ -.DS_Store -*.swp -*.swo - -# Local runtime/generated deployment files -.env -.env.* -!.env.example -.env.prod.file -install_settings.txt -wetlab/logging_config.ini logs/ tmp/ documents/ -outputs/ -catboost_info/ - -# Build, package, and generated archives build/ dist/ *.egg-info/ -.eggs/ -*.egg -*.log -*.gz -*.zip -*.tar -*.tgz - -# Generated Django/runtime files at repo root -/manage.py -/static/ diff --git a/.gitignore b/.gitignore index b38b025e2..2d9f31ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,124 +1,21 @@ -## Custom -*.xml -*.bin -*.gz -*_BAK -tmp/ -logs/ -/static/ -install_settings.txt +# Local deployment configuration and generated synchronization candidates +.env +.env.*.file +conf/production_settings.txt +# Legacy/application-specific production settings must remain local. my_prod_settings*.txt -wetlab/logging_config.ini -#--- updates introduced for iSkyLIMS in submodules packaging --- -virtualenv/ -iSkyLIMS/ -*iskylims/ -documents/ -manage.py -logs/ -logs -.vscode/ - +*.bu-isciii-update +# Final add-on bind sources are regenerated from repository-owned conf files. +deployment/apache/*.conf +deployment/settings/ -# Byte-compiled / optimized / DLL files +# Python and runtime artifacts __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv -.env -.env.prod.file - -# virtualenv -.venv -venv/ -ENV/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ +.venv/ +virtualenv/ +node_modules/ +.next/ +.npm/ +logs/ +documents/ diff --git a/Dockerfile b/Dockerfile index bdcabed8b..8f323f0a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,104 +1,68 @@ -FROM registry.access.redhat.com/ubi9/ubi -ENV TZ=Europe/Madrid -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +# syntax=docker/dockerfile:1.4 +FROM registry.access.redhat.com/ubi9/ubi-minimal -# Runtime user (override with build args if needed) ARG APP_UID=1212 ARG APP_GID=1212 -ARG APP_SHELL=/sbin/nologin -ARG INSTALL_PATH=/opt/iskylims -ENV INSTALL_PATH=${INSTALL_PATH} -ENV PIP_NO_CACHE_DIR=1 - -# Updates -RUN dnf -y update - -# Add EPEL for packages not available in default UBI repositories -RUN dnf -y install --setopt=install_weak_deps=False --nodocs \ - https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm - -# Essential software -RUN dnf -y install --setopt=install_weak_deps=False --nodocs \ - git wget \ - python3.11 python3.11-pip python3.11-devel python3.11-wheel \ - gcc gcc-c++ make \ - openssl-devel libffi-devel \ - mariadb mariadb-connector-c-devel \ - httpd-devel cronie \ - rsync tzdata \ - pkgconf-pkg-config \ - gnuplot-minimal \ - && dnf clean all \ - && rm -rf /var/cache/dnf /tmp/* /var/tmp/* - -# Install supercronic (rootless-friendly cron runner) +ARG APP_PORT=8001 +ARG APP_REPO_PATH=/srv/iskylims +ARG APP_INSTALL_PATH=/opt/iskylims +ARG GIT_REVISION=current +ARG INSTALL_CONF=conf/docker_test_settings.txt +ARG USE_INSTALL_CONF_SECRET=false +ARG RENDER_DJANGO_SETTINGS=false + +ENV APP_REPO_PATH=${APP_REPO_PATH} \ + APP_INSTALL_PATH=${APP_INSTALL_PATH} \ + INSTALL_PATH=${APP_INSTALL_PATH} \ + PATH=${APP_INSTALL_PATH}/virtualenv/bin:${PATH} \ + APP_PORT=${APP_PORT} \ + PROJECT_MODULE=iskylims \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Europe/Madrid + +RUN microdnf -y update && \ + microdnf -y install python3.11 python3.11-pip \ + tar gcc git rsync wget tzdata && \ + microdnf clean all + +ARG SUPERCRONIC_VERSION=v0.2.38 RUN set -eux; \ - SUPERCRONIC_VERSION="v0.2.38"; \ arch="$(uname -m)"; \ case "$arch" in \ x86_64) supercronic_arch="amd64" ;; \ aarch64) supercronic_arch="arm64" ;; \ *) echo "Unsupported architecture for supercronic: $arch" >&2; exit 1 ;; \ esac; \ - supercronic_url="https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-${supercronic_arch}"; \ - if wget --tries=3 --waitretry=2 --retry-connrefused -q -O /usr/local/bin/supercronic "${supercronic_url}"; then \ - chmod +x /usr/local/bin/supercronic; \ - else \ - rm -f /usr/local/bin/supercronic; \ - echo "supercronic download failed from ${supercronic_url}; continuing without cron support"; \ + wget -q -O /usr/local/bin/supercronic \ + "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-${supercronic_arch}"; \ + chmod 0755 /usr/local/bin/supercronic + +WORKDIR ${APP_REPO_PATH} +COPY . ${APP_REPO_PATH}/ +COPY scripts/container_start.sh /usr/local/bin/container_start.sh + +# Production uses an ephemeral build-secret mount: the operator configuration +# is readable by install.sh but never copied into an image layer. Test builds +# use the committed safe profile and explicitly render test settings. +RUN --mount=type=secret,id=install_conf \ + conf_path="${INSTALL_CONF}"; \ + if [ "${USE_INSTALL_CONF_SECRET}" = "true" ]; then \ + conf_path=/run/secrets/install_conf; \ + test -f "$conf_path" || { echo "Required install_conf build secret is missing" >&2; exit 1; }; \ fi; \ - rm -rf /tmp/* /var/tmp/* - -# Ensure python3 points to the desired version -RUN ln -sf /usr/bin/python3.11 /usr/bin/python3 - -# Install Illumina InterOp CLI used to generate run metric plots -RUN set -eux; \ - cd /opt; \ - wget -q https://github.com/Illumina/interop/releases/download/v1.1.15/InterOp-1.1.15-Linux-GNU.tar.gz; \ - tar -xf InterOp-1.1.15-Linux-GNU.tar.gz; \ - ln -s InterOp-1.1.15-Linux-GNU interop; \ - rm InterOp-1.1.15-Linux-GNU.tar.gz; \ - rm -rf /tmp/* /var/tmp/* - -# Set git repository -RUN mkdir /srv/iskylims -WORKDIR /srv/iskylims - -# Copy the local git repository to docker image directory -COPY --chown=${APP_UID}:${APP_GID} . /srv/iskylims - -ENV PATH="/usr/sbin/cron:$PATH" -RUN chmod +x /srv/iskylims/scripts/container_start.sh - -# Set default install type -ARG INSTALL_TYPE=dep -ARG GIT_REVISION=main -ARG INSTALL_CONF=conf/docker_test_settings.txt - -# Prepare dependencies and stage the application tree in the image so the -# container can restart without rerunning install-time file generation. -ENV SKIP_SYSTEM_PACKAGES=1 -RUN /bin/bash install.sh --install dep --git_revision $GIT_REVISION --conf $INSTALL_CONF --skip_apache_restart \ - && rm -rf /root/.cache/pip /tmp/* /var/tmp/* -RUN /bin/bash install.sh --stage install --git_revision $GIT_REVISION --conf $INSTALL_CONF --skip_apache_restart \ - && rm -rf /root/.cache/pip /tmp/* /var/tmp/* -# Use the virtualenv created by install.sh -ENV PATH="${INSTALL_PATH}/virtualenv/bin:${PATH}" - -WORKDIR ${INSTALL_PATH} - -# Create non-root user and set ownership -RUN groupadd -g ${APP_GID} iskylims && \ - useradd -m -u ${APP_UID} -g ${APP_GID} -s ${APP_SHELL} iskylims && \ - mkdir -p ${INSTALL_PATH}/cron ${INSTALL_PATH}/tmp && \ - chown -R ${APP_UID}:${APP_GID} ${INSTALL_PATH} /srv/iskylims && \ - chmod 700 ${INSTALL_PATH}/cron ${INSTALL_PATH}/tmp && \ - git config --system --add safe.directory /srv/iskylims - -# Expose -EXPOSE 8001 - -# Start the application once install.sh has populated /opt/iskylims. -USER iskylims -CMD ["/srv/iskylims/scripts/container_start.sh"] + render_args=""; \ + if [ "${RENDER_DJANGO_SETTINGS}" = "true" ]; then render_args="--render-settings"; fi; \ + bash install.sh --stage install \ + --git_revision "${GIT_REVISION}" --conf "$conf_path" $render_args && \ + groupadd -g "${APP_GID}" app && \ + useradd -u "${APP_UID}" -g "${APP_GID}" -s /sbin/nologin app && \ + chmod 0755 /usr/local/bin/container_start.sh && \ + chown -R "${APP_UID}:${APP_GID}" "${APP_INSTALL_PATH}" "${APP_REPO_PATH}" + +WORKDIR ${APP_INSTALL_PATH} +USER ${APP_UID}:${APP_GID} +EXPOSE ${APP_PORT} +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ['APP_PORT'] + '/health/', timeout=3)" || exit 1 +CMD ["/usr/local/bin/container_start.sh"] diff --git a/LEAME.md b/LEAME.md index 5631daf5d..5f60e4136 100644 --- a/LEAME.md +++ b/LEAME.md @@ -1,389 +1,418 @@ # Actualizacion de iSkyLIMS con Podman rootless -Esta guia es para la actualizacion del despliegue institucional de iSkyLIMS usando Podman rootless. Se asume que iSkyLIMS ya existe en la institucion, que la base de datos de produccion ya esta creada y que el objetivo es actualizar o recrear los contenedores de aplicacion sin crear una base de datos desde cero. - -`container_install.sh` se encarga de construir la imagen, arrancar los contenedores, preparar permisos, actualizar configuracion, aplicar migraciones, refrescar estaticos y ejecutar los pasos necesarios de actualizacion. +Esta guia es la lista de ejecucion para instalar, actualizar y recuperar el +despliegue de produccion. Los comandos generados son reutilizables. Antes de la +aprobacion, el responsable debe registrar las entradas de despliegue indicadas +abajo con valores o referencias institucionales verificadas. ## Indice -- [Actualizacion de iSkyLIMS con Podman rootless](#actualizacion-de-iskylims-con-podman-rootless) - - [Indice](#indice) - - [Requisitos](#requisitos) - - [Estructura de directorios en los servidores](#estructura-de-directorios-en-los-servidores) - - [Preparar directorios del host](#preparar-directorios-del-host) - - [Actualizar codigo](#actualizar-codigo) - - [Configurar `my_prod_settings_iskylims.txt`](#configurar-my_prod_settings_iskylimstxt) - - [Backup antes de actualizar](#backup-antes-de-actualizar) - - [Ejecutar la actualizacion](#ejecutar-la-actualizacion) - - [Comprobaciones posteriores](#comprobaciones-posteriores) - - [Rollback](#rollback) - - [Reparar permisos](#reparar-permisos) - - [Operaciones utiles](#operaciones-utiles) - - [Notas de permisos](#notas-de-permisos) +- [Requisitos](#requisitos) +- [Estructura de directorios en los servidores](#estructura-de-directorios-en-los-servidores) +- [Preparar checkout y backup](#preparar-checkout-y-backup) +- [Actualizar codigo](#actualizar-codigo) +- [Configurar los ajustes de produccion](#configurar-los-ajustes-de-produccion) +- [Preparar directorios persistentes del host](#preparar-directorios-persistentes-del-host) +- [Backup antes de actualizar](#backup-antes-de-actualizar) +- [Ejecutar la actualizacion](#ejecutar-la-actualizacion) +- [Comprobaciones posteriores](#comprobaciones-posteriores) +- [Rollback](#rollback) +- [Reparar permisos](#reparar-permisos) +- [Operaciones utiles](#operaciones-utiles) +- [Notas de permisos](#notas-de-permisos) ## Requisitos -El despliegue usa: - -- Podman rootless ejecutado por un usuario normal del sistema. -- `podman-compose` o `podman compose`. -- `container_install.sh` desde el repositorio de iSkyLIMS. -- `docker-compose.prod.yml`, lanzado con Podman. -- Una base de datos MySQL/MariaDB externa ya existente. -- Una configuracion Samba/storage ya existente o configurada desde la interfaz. -- Bind mounts del host para logs, configuracion Apache y `settings.py`. -- Volumenes Podman para `documents` y `static`. +- Podman rootless y un proveedor de Compose funcionales. +- El mismo usuario sin privilegios para el instalador y Podman. +Entradas de despliegue que deben quedar registradas antes de ejecutar: -Comprueba que Podman funciona sin root: +| Entrada | Evidencia requerida | +|---|---| +| Revision aprobada | Tag o commit inmutable y aprobacion asociada | +| Exposicion publica | URL, DNS, propietario de TLS y reglas de proxy | +| Dependencias | Base de datos, almacenamiento, correo e identidad aplicables | +| Operacion | Responsable del servicio y contacto de escalado | +| Recuperacion | RPO, RTO, retencion y ubicacion de backups | ```bash podman info -podman ps +podman compose version || podman-compose --version ``` -No ejecutes `container_install.sh` con `sudo`. El usuario que ejecuta Podman debe ser el mismo usuario que ejecuta `container_install.sh`. - -Los ejemplos usan `podman compose`. Si tu servidor solo tiene `podman-compose`, sustituye `podman compose` por `podman-compose`. +No ejecutar `container_install.sh` con `sudo`. Podman rootless, el proveedor de +Compose y el instalador deben usar siempre la misma cuenta. Los ejemplos usan +`podman compose`; si el host proporciona `podman-compose`, sustituir ese prefijo +completo. La libreria compartida detecta ambos proveedores automaticamente. ## Estructura de directorios en los servidores -Los servidores de desarrollo, preproduccion y produccion siguen la misma convencion de directorios. Cada tipo de informacion tiene una ubicacion concreta para separar el codigo y la configuracion del despliegue, los datos gestionados por Podman, los bind mounts y los logs. La estructura siguiente es orientativa: solo muestra una aplicacion como ejemplo y no pretende enumerar todos los directorios o ficheros existentes. +Todos los despliegues usan esta estructura institucional. El despliegue separa +sus fuentes, binds, logs y backups; las rutas protegidas pueden definir un +namespace distinto. Podman administra su propio storage y no debe modificarse +manualmente. ```text /opt/containers_apps/ └── iskylims/ - ├── backup/ # Opcional: backups propios del despliegue - └── iskylims/ # Clon Git, fuentes y configuracion de instalacion + ├── backup/ # Backups locales opcionales + └── iskylims/ # Clone Git y configuracion protegida /srv/containers/ -├── backup/ # Backups centralizados, si no estan junto al despliegue +├── backup/ +│ └── iskylims/ # Backup central recomendado ├── bind/ -│ └── iskylims/ # Bind mounts organizados por aplicacion -│ ├── iskylims_apache_conf/ -│ ├── iskylims_app_setting/ -│ └── iskylims_django_setting/ -├── shared/ # Datos compartidos entre aplicaciones, cuando proceda +│ └── relecov-iskylims/ +│ └── settings/ # settings.py renderizado por servicio +├── shared/ # Datos compartidos entre aplicaciones └── storage/ - └── / # Almacenamiento interno rootless de Podman - ├── overlay/ - ├── overlay-containers/ - ├── overlay-images/ - └── volumes/ # Volumenes persistentes gestionados por Podman + └── / # Storage rootless gestionado por Podman /var/log/local/ -└── iskylims/ - ├── apache/ # Logs del servidor web y de ModSecurity - └── apps/ # Logs de aplicacion, cron y procesos auxiliares +└── relecov-iskylims/ + ├── apache/ + └── apps/ ``` -Uso de cada ubicacion: +Persistencia declarada por el despliegue: -- `/opt/containers_apps//` agrupa una aplicacion o un conjunto de aplicaciones que se despliegan juntas. Contiene los clones Git, el codigo fuente y los ficheros de configuracion usados por la instalacion. La instalacion se ejecuta desde este directorio. Puede incluir un directorio `backup/` para backups propios del despliegue. -- `/srv/containers/backup/` es la ubicacion alternativa para centralizar los distintos tipos de backup. Cada despliegue debe elegir de forma coherente entre esta ruta y su directorio `backup/` bajo `/opt/containers_apps/`. -- `/srv/containers/bind//` contiene exclusivamente las rutas del host que se montan como bind mounts. Deben estar separadas por aplicacion y creadas con los propietarios y permisos requeridos antes de arrancar los contenedores. Estos permisos se gestionan mediante el script de instalación. -- `/srv/containers/storage//` contiene la estructura de almacenamiento rootless de Podman, incluidos sus metadatos, capas, imagenes y volumenes. Podman gestiona esta estructura; no se deben cambiar manualmente sus propietarios o permisos. En los servidores actuales, `` puede ser, por ejemplo, `bioinfo`. -- `/srv/containers/shared/` se reserva para datos que deban compartir varias aplicaciones. -- `/var/log/local//` centraliza los logs persistentes del host. Como norma general, `apache/` contiene los logs del servidor web y `apps/` los de la aplicacion y sus procesos auxiliares. Algunas aplicaciones pueden necesitar subdirectorios adicionales. +| Activo | Ubicacion de produccion | Requisito de recuperacion | +|---|---|---| +| `iskylims` database | External production database | Database backup before migration | +| `iskylims` documents | `iskylims_documents` named volume | Volume backup | +| `iskylims` static | `iskylims_static` named volume | Replaceable through collectstatic | +| `iskylims` logs | Host bind configured by `HOST_LOG_PATH` in `iskylims_production_settings.txt` | Retain/rotate per institutional log policy | +| `iskylims` rendered settings | Host bind configured by `DJANGO_SETTINGS_PATH` in `iskylims_production_settings.txt` | Protected configuration backup | +| Apache logs | `/var/log/local/iskylims/apache` host bind | Retain/rotate per institutional log policy | +| Rendered Apache configuration | `deployment/apache/` in the deployment checkout | Rebuildable; preserve reviewed source configuration | +| Samba test data | `samba_test_data` named volume | Disposable test/demo files | -Otros despliegues, como `beacon`, `localega` o `relecov-platform`, repiten esta misma separacion bajo su propio nombre. Esta convencion es importante al aplicar permisos: el codigo, los bind mounts, los volumenes gestionados por Podman y los logs no deben tratarse como si fueran el mismo tipo de almacenamiento. +## Preparar checkout y backup -## Preparar directorios del host - -Los bind mounts son rutas reales del host. Deben existir y pertenecer al usuario que ejecuta `container_install.sh`. - -Ejemplo recomendado: +Crear solo las ubicaciones necesarias para obtener el codigo y guardar backups. +Sustituir `` por la cuenta que ejecutara siempre Podman y el +instalador; normalmente es la cuenta de la sesion actual. Los binds y logs se +crean despues de completar los ajustes protegidos. ```bash -sudo mkdir -p /var/log/local/relecov-iskylims/apps -sudo mkdir -p /var/log/local/relecov-iskylims/apache -sudo mkdir -p /srv/containers/bind/iskylims/iskylims_apache_conf -sudo mkdir -p /srv/containers/bind/iskylims/iskylims_django_setting - -sudo chown -R "_USER-RUNNING_PODMAN_:_USER-RUNNING_PODMAN_" /var/log/local/relecov-iskylims -sudo chown -R "_USER-RUNNING_PODMAN_:_USER-RUNNING_PODMAN_" /srv/containers/bind/iskylims +sudo mkdir -p /opt/containers_apps/iskylims +sudo mkdir -p /srv/containers/backup/iskylims +sudo chown -R : \ + /opt/containers_apps/iskylims \ + /srv/containers/backup/iskylims ``` -Si usas otras rutas para `APACHE_CONF_PATH` o `DJANGO_SETTINGS_PATH`, crea esas rutas y asignales la misma propiedad. - -`container_install.sh` ajustara despues los permisos internos con `podman unshare` y con `podman exec --user 0` cuando el contenedor este levantado. - ## Actualizar codigo -Entra en el repositorio como el usuario que ejecuta Podman: +Para un checkout nuevo: ```bash -cd /ruta/al/repositorio/iskylims -git pull -``` - -Si todavia no existe el repositorio en el servidor: - -```bash -git clone https://gitlab.isciii.es/bu-isciii/iSkyLIMS.git iskylims +cd /opt/containers_apps/iskylims +git clone https://github.com/BU-ISCIII/iSkyLIMS.git iskylims cd iskylims +git checkout ``` -## Configurar `my_prod_settings_iskylims.txt` +En un checkout existente, verificar primero que no haya cambios locales y +cambiar a la revision entregada mediante el procedimiento Git de la institucion. +Registrar el commit exacto con `git rev-parse HEAD`. -Si el fichero ya existe, revisalo y mantenlo. Si no existe, copialo desde la plantilla: +## Configurar los ajustes de produccion -```bash -cp conf/docker_production_settings.txt conf/my_prod_settings_iskylims.txt -``` - -Edita: +Crear un fichero ignorado y con modo `0600` por servicio a partir de su +`conf/docker_production_settings.txt`. Resolver todos los `CHANGE_ME` y revisar +la matriz [`conf/INSTALL_SETTINGS.md`](conf/INSTALL_SETTINGS.md). El instalador +genera `.env.production.file` con valores runtime, incluidos secretos copiados +desde estos ficheros protegidos. Mantenerlo con modo `0600`, fuera de Git y +dentro del backup protegido de configuracion. Ninguno de estos ficheros se +copia en las capas de las imagenes. ```bash -nano conf/my_prod_settings_iskylims.txt +install -d -m 0700 deployment/settings +install -m 0600 conf/docker_production_settings.txt deployment/settings/iskylims_production_settings.txt +install -m 0600 conf/apache/apache_production_settings.txt deployment/settings/apache_production_settings.txt +install -m 0600 conf/samba/samba_production_settings.txt deployment/settings/samba_production_settings.txt ``` -Valores principales: - -```bash -INSTALL_PATH='/opt/iskylims' - -APACHE_CONF_PATH='/srv/containers/bind/iskylims/iskylims_apache_conf' -DJANGO_SETTINGS_PATH='/srv/containers/bind/iskylims/iskylims_django_setting/settings.py' +Valores que requieren decision del responsable de la aplicacion: -SERVER_STATUS_SERVER_NAME='' -SERVER_STATUS_ALIASES='127.0.0.1 localhost' -SERVER_STATUS_ALLOW_FROM='127.0.0.1 localhost' +- hostnames publicos, TLS y proxy; +- base de datos y credenciales de minimo privilegio; +- rutas persistentes, UID/GID, SELinux y politica de backup; +- correo, identidad, almacenamiento y ajustes propios de la aplicacion; +- administrador inicial y transferencia segura de sus credenciales. -APACHE_FORWARDED_PROTO='https' -APACHE_FORWARDED_PORT='443' +Editar unicamente las copias bajo `deployment/settings/`, completar todas esas +decisiones y resolver cada `CHANGE_ME` antes de continuar. Los comandos de +instalacion y actualizacion usan estas rutas protegidas. -APP_UID='1212' -APP_GID='1212' -APP_SHELL='/sbin/nologin' -APP_PORT='8001' +## Preparar directorios persistentes del host -DB_USER='' -DB_PASS='' -DB_NAME='iskylims' -DB_SERVER_IP='' -DB_PORT=3306 +Solo despues de completar y revisar todos los ajustes, crear los binds +exactamente donde indica cada servicio. Los ficheros se cargan como el usuario +actual dentro de subshells; solo `install -d` usa privilegios. -EMAIL_HOST_SERVER='docker.container.internal' -EMAIL_PORT='25' -EMAIL_HOST_USER='' -EMAIL_HOST_PASSWORD='' -EMAIL_USE_TLS='False' - -LOCAL_SERVER_IP='*' -DNS_URL='' +```bash +PODMAN_USER='' +( + source deployment/settings/iskylims_production_settings.txt + : "${HOST_LOG_PATH:?HOST_LOG_PATH is required for iskylims}" + : "${DJANGO_SETTINGS_PATH:?DJANGO_SETTINGS_PATH is required for iskylims}" + sudo install -d -o "$PODMAN_USER" -g "$PODMAN_USER" \ + "$HOST_LOG_PATH" "$(dirname "$DJANGO_SETTINGS_PATH")" +) +( + source deployment/settings/apache_production_settings.txt + : "${APACHE_LOG_PATH:?APACHE_LOG_PATH is required for apache}" + sudo install -d -o "$PODMAN_USER" -g "$PODMAN_USER" "$APACHE_LOG_PATH" +) ``` -Notas: +Revisar las rutas resueltas antes de ejecutar. No usar valores procedentes de +una configuracion no revisada y no ejecutar los ficheros completos con `sudo`. +Aplicar despues UID/GID internos, modos y etiquetas SELinux mediante el +instalador. No modificar `/srv/containers/storage/` manualmente. -- `INSTALL_PATH` es la ruta dentro del contenedor. -- `APACHE_CONF_PATH` es una ruta del host donde se escriben los ficheros Apache renderizados. -- `DJANGO_SETTINGS_PATH` es una ruta del host para el `settings.py` montado en el contenedor. -- `DB_*` debe apuntar a la base de datos de produccion existente. -- Mantener el mismo `APP_UID` y `APP_GID` en todas las actualizaciones evita problemas de permisos. +```bash +bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt +``` ## Backup antes de actualizar -Haz siempre backup antes de ejecutar la actualizacion. - -Crea una carpeta de backup: +Crear un directorio identificado y registrar el estado desplegado: ```bash -BACKUP_DIR=~/iskylims_backup_$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="/srv/containers/backup/iskylims/$(date +%Y%m%d_%H%M%S)" mkdir -p "$BACKUP_DIR" +git rev-parse HEAD > "$BACKUP_DIR/git-revision.txt" +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + images > "$BACKUP_DIR/images.txt" +cp .env.production.file "$BACKUP_DIR/" +cp deployment/settings/iskylims_production_settings.txt "$BACKUP_DIR/" +cp deployment/settings/apache_production_settings.txt "$BACKUP_DIR/" +cp deployment/settings/samba_production_settings.txt "$BACKUP_DIR/" +chmod -R go-rwx "$BACKUP_DIR" ``` -Backup de base de datos: +Exportar la base de datos externa desde un punto coherente: ```bash -mysqldump --user= --password --host= --port= iskylims > "$BACKUP_DIR/iskylims.sql" +mysqldump --single-transaction --routines --triggers \ + --host= --port= --user= --password \ + > "$BACKUP_DIR/database.sql" ``` -Backup de volumenes Podman: +Localizar y exportar cada volumen no reconstruible declarado en la tabla: ```bash -podman volume ls | grep iskylims -podman volume export iskylims_iskylims_documents > "$BACKUP_DIR/iskylims_documents.tar" -podman volume export iskylims_iskylims_static > "$BACKUP_DIR/iskylims_static.tar" +podman volume ls | grep 'iskylims' +podman volume export > "$BACKUP_DIR/documents.tar" +podman volume export > "$BACKUP_DIR/static.tar" ``` -Backup de configuracion: +Exportar `documents` y `static` por cada servicio Django que los declare; +omitir esos comandos para perfiles sin dichos volumenes. Aunque `static` puede +regenerarse con `collectstatic`, conservarlo permite una restauracion exacta. + +Guardar tambien los bind mounts persistentes. Los logs se conservan segun su +politica de retencion; la configuracion protegida debe incluirse siempre. ```bash -cp conf/my_prod_settings_iskylims.txt "$BACKUP_DIR/" -cp .env.prod.file "$BACKUP_DIR/" 2>/dev/null || true +tar -C /srv/containers/bind -czf "$BACKUP_DIR/bind-mounts.tar.gz" iskylims +tar -C /var/log/local -czf "$BACKUP_DIR/logs.tar.gz" iskylims +sha256sum "$BACKUP_DIR"/* > "$BACKUP_DIR/SHA256SUMS" ``` +No continuar hasta verificar los ficheros, espacio disponible y procedimiento +de restauracion. + ## Ejecutar la actualizacion -Para la mayoria de actualizaciones: +Ejecutar el comando de instalación/upgrade: ```bash -bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action upgrade 2>&1 | tee ./iskylims_podman_upgrade_$(date +%Y%m%d_%H%M%S).log +bash container_install.sh --action upgrade --engine podman \ + --git_revision \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt 2>&1 | tee "$(date +%Y%m%d_%H%M%S)_prod_install.log" ``` -El script: +Durante `--action upgrade`, `container_install.sh`: -- construye una nueva imagen; -- arranca o recrea los contenedores necesarios; -- genera `.env.prod.file`; -- renderiza configuracion Apache; -- prepara `settings.py`; -- repara permisos de bind mounts y volumenes; -- ejecuta `install.sh --bootstrap upgrade`; -- aplica migraciones; -- refresca `collectstatic`. +1. valida opciones, configuraciones protegidas y Compose antes de modificar el + despliegue; +2. genera `.env.production.file` y las configuraciones runtime protegidas; +3. prepara bind mounts, propietarios, modos y etiquetas SELinux; +4. construye las imagenes desde la revision aprobada; +5. recrea la topologia conservando volumenes y bind mounts persistentes; +6. espera readiness y repara los volumenes desde los contenedores en ejecucion; +7. ejecuta el bootstrap requerido por cada perfil —checks, migraciones, + scripts/fixtures y `collectstatic` para Django—; +8. ejecuta el smoke test y solo entonces declara completada la actualizacion. -No usa la accion `install` porque este procedimiento asume una base de datos institucional ya existente. - -Si la version instalada necesita scripts de migracion de datos, sigue la [guia de actualizacion especifica para esa version](docs/upgrades/README.md) en lugar de ejecutar solamente el comando generico. +Seguir ademas la guia especifica de la version cuando exista. Detenerse ante +cualquier fallo de build, readiness, bootstrap, migracion o smoke test. ## Comprobaciones posteriores -Comprueba contenedores: - -```bash -podman compose --env-file .env.prod.file -f docker-compose.prod.yml ps -``` - -Revisa logs: - ```bash -podman compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 app -podman compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 apache +podman compose --env-file .env.production.file -f docker-compose.prod.yml ps +podman compose --env-file .env.production.file -f docker-compose.prod.yml logs --tail 200 +bash scripts/smoke_test.sh --engine podman ``` -Comprueba la aplicacion: +Completar las comprobaciones que corresponden a la topologia seleccionada: -```text -http://:8080 -``` +- `iskylims`: confirmar su endpoint `/health/` y un flujo representativo de lectura. +- Apache: confirmar la URL publica registrada, DNS/TLS, proxy, cabeceras reenviadas y el endpoint restringido de server-status. +- Samba: en cada modo habilitado, confirmar acceso autenticado y un flujo representativo de lectura/escritura desde un cliente aprobado. -Si se han cambiado parametros de runtime en `conf/my_prod_settings_iskylims.txt`, vuelve a ejecutar `container_install.sh` para regenerar `.env.prod.file` y recrear los contenedores de forma coherente. +Verificar tambien correo y tareas programadas. Registrar URL y resultados junto +con estado, imagenes y revision desplegada. ## Rollback -Si la actualizacion falla y necesitas volver atras: - -1. Deten contenedores: - - ```bash - podman compose --env-file .env.prod.file -f docker-compose.prod.yml down - ``` - -2. Vuelve al commit o tag anterior del codigo: +Si el esquema y los formatos persistentes siguen siendo compatibles, desplegar +la revision anterior registrada y repetir las pruebas: - ```bash - git checkout - ``` - -3. Restaura la base de datos: - - ```bash - mysql --user= --password --host= --port= iskylims < "$BACKUP_DIR/iskylims.sql" - ``` - -4. Restaura volumenes si es necesario: - - ```bash - podman volume import iskylims_iskylims_documents "$BACKUP_DIR/iskylims_documents.tar" - podman volume import iskylims_iskylims_static "$BACKUP_DIR/iskylims_static.tar" - ``` - -5. Restaura configuracion si cambio: - - ```bash - cp "$BACKUP_DIR/my_prod_settings_iskylims.txt" conf/my_prod_settings_iskylims.txt - ``` +```bash +bash container_install.sh --action upgrade --engine podman \ + --git_revision \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt +``` -6. Repara permisos, arranca y vuelve a reparar volumenes montados: +Si no son compatibles, detener escrituras y restaurar el punto completo: - ```bash - bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions - podman compose --env-file .env.prod.file -f docker-compose.prod.yml up -d - bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions - ``` +```bash +podman compose --env-file .env.production.file -f docker-compose.prod.yml down +mysql --host= --port= --user= --password \ + < "$BACKUP_DIR/database.sql" +podman volume import "$BACKUP_DIR/documents.tar" +podman volume import "$BACKUP_DIR/static.tar" +tar -C /srv/containers/bind -xzf "$BACKUP_DIR/bind-mounts.tar.gz" +install -d -m 0700 deployment/settings +install -m 0600 "$BACKUP_DIR/iskylims_production_settings.txt" deployment/settings/iskylims_production_settings.txt +install -m 0600 "$BACKUP_DIR/apache_production_settings.txt" deployment/settings/apache_production_settings.txt +install -m 0600 "$BACKUP_DIR/samba_production_settings.txt" deployment/settings/samba_production_settings.txt +bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt -7. Revisa logs: +``` - ```bash - podman compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 app - podman compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 apache - ``` +Restaurar todos los ficheros de ajustes protegidos y desplegar la revision +anotada en `git-revision.txt`. `fix-permissions` regenera +`.env.production.file` antes de cualquier restauracion gestionada por un +add-on. Arrancar y validar antes de reabrir el servicio. Los volumenes deben +existir y estar vacios antes de `podman volume import`; recrearlos con Compose +cuando sea necesario. ## Reparar permisos -Ejecuta esta accion si: +Ejecutar esta accion cuando: + +- se hayan creado o restaurado bind mounts o volumenes; +- se hayan recreado contenedores manualmente; +- hayan cambiado `APP_UID`, `APP_GID` o el usuario rootless; +- existan errores de escritura en logs, documentos, static o configuracion; +- SELinux rechace un bind mount revisado; +- Apache o la aplicacion fallen por propietarios/modos incorrectos. -- se han recreado contenedores manualmente; -- se han restaurado volumenes; -- se han cambiado propietarios en el host; -- se han cambiado `APP_UID` o `APP_GID`; -- el contenedor no arranca por permisos de bind mounts. +Primera fase, incluso con los contenedores detenidos: ```bash -bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions +bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt ``` -Si el contenedor no esta arrancado, esta accion repara solo los bind mounts del host. Despues arranca los contenedores y repite la accion para reparar los volumenes montados: +Esta accion no construye imagenes, no migra la base de datos y no borra datos. +Con los contenedores detenidos repara los bind mounts accesibles desde el host. +Arrancar y repetirla para reparar tambien los volumenes montados: ```bash -bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions -podman compose --env-file .env.prod.file -f docker-compose.prod.yml up -d -bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions +podman compose --env-file .env.production.file -f docker-compose.prod.yml up -d +bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt ``` ## Operaciones utiles -Usa siempre `.env.prod.file` al ejecutar Podman Compose directamente: - ```bash -podman compose --env-file .env.prod.file -f docker-compose.prod.yml ps -podman compose --env-file .env.prod.file -f docker-compose.prod.yml up -d -podman compose --env-file .env.prod.file -f docker-compose.prod.yml restart app -podman compose --env-file .env.prod.file -f docker-compose.prod.yml down +podman compose --env-file .env.production.file -f docker-compose.prod.yml ps +podman compose --env-file .env.production.file -f docker-compose.prod.yml logs --tail 200 +podman compose --env-file .env.production.file -f docker-compose.prod.yml up -d +podman compose --env-file .env.production.file -f docker-compose.prod.yml restart +podman compose --env-file .env.production.file -f docker-compose.prod.yml down ``` -Entrar al contenedor: +### Servicio Django `iskylims` ```bash -podman exec -it iskylims_app bash +# Logs separados del servicio. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + logs --tail 200 iskylims + +# Entrar al contenedor. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash + +# Regenerar static sin ejecutar migraciones. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash -lc \ + 'cd "$INSTALL_PATH" && source virtualenv/bin/activate && python manage.py collectstatic --noinput' + +# Diagnostico previo a una recuperacion de bootstrap. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash -lc \ + 'cd "$INSTALL_PATH" && source virtualenv/bin/activate && python manage.py check --deploy && python manage.py showmigrations --plan' ``` -Ejecutar `collectstatic` manualmente: +La recuperacion preferida es corregir la causa y repetir +`container_install.sh --action install|upgrade` con la misma revision y +configuracion protegida. Si el instalador no puede completarse y el responsable +autoriza un bootstrap manual despues del backup: ```bash -podman exec -it iskylims_app bash -lc 'cd /opt/iskylims && source virtualenv/bin/activate && python manage.py collectstatic --noinput' +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash -lc \ + 'cd "$INSTALL_PATH" && source virtualenv/bin/activate && python manage.py migrate --noinput && python manage.py collectstatic --noinput' ``` -Ejecutar manualmente el bootstrap de actualizacion: +Registrar este procedimiento excepcional y ejecutar despues el smoke test. + +### Servicio Apache ```bash -podman exec -it iskylims_app bash -c 'cd /srv/iskylims && bash install.sh --bootstrap upgrade --git_revision main --conf conf/my_prod_settings_iskylims.txt --tables --skip_apache_restart' +# Logs separados de Apache y validacion de configuracion. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + logs --tail 200 iskylims-apache +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims-apache httpd -t + +# Estado restringido; usar valores del fichero protegido. +APACHE_PORT='CHANGE_ME' +SERVER_STATUS_SERVER_NAME='localhost' +curl --fail --show-error \ + --header "Host: $SERVER_STATUS_SERVER_NAME" \ + "http://127.0.0.1:$APACHE_PORT/server-status?auto" ``` -## Notas de permisos - -Bind mounts: - -- Son rutas reales del host. -- Deben existir antes de arrancar contenedores. -- Deben pertenecer al usuario que ejecuta Podman rootless. -- `container_install.sh` usa `podman unshare` para aplicar propietarios internos cuando hace falta. - -Volumenes Podman: - -- Los gestiona Podman en el almacenamiento rootless del usuario. -- Se reparan desde dentro del contenedor con `podman exec --user 0`. -- Si cambias `APP_UID` o `APP_GID`, ejecuta `--action fix-permissions`. +Para diagnosticos SELinux y ModSecurity, comprobar el bind de logs antes de +reiniciar: -Apache: +```bash +ls -ldZ /var/log/local/iskylims/apache +``` -- El contenedor Apache UBI usa UID `1001` y grupo `0`. -- Los logs Apache se preparan para ese usuario. -- Los ficheros Apache renderizados se dejan con permisos `0664`. +Si aparece `ModSecurity: Failed to open debug log file`, conservar el fichero +para diagnostico, ejecutar `fix-permissions` y reiniciar. Si hay que sustituir +el inode, moverlo primero a un backup en vez de borrarlo. -`settings.py`: +## Notas de permisos -- Se monta desde el host. -- `container_install.sh` lo prepara con permisos `0664`. -- Si editas el fichero a mano, ejecuta despues `--action fix-permissions`. +- Ejecutar siempre Podman y el instalador con el mismo usuario rootless. +- No usar `sudo container_install.sh` ni cambiar propietarios dentro del storage + de Podman. +- Mantener estables los UID/GID de runtime entre actualizaciones. +- Revisar etiquetas SELinux y propietarios de bind mounts mediante + `fix-permissions`. +- Preservar evidencias y backups antes de cualquier recuperacion destructiva. diff --git a/README.md b/README.md index d2fa315c8..f44b608d2 100644 --- a/README.md +++ b/README.md @@ -1,322 +1,283 @@ # iSkyLIMS -[![Django](https://img.shields.io/static/v1?label=Django&message=4.2&color=blue?style=plastic&logo=django)](https://github.com/django/django) -[![Python](https://img.shields.io/static/v1?label=Python&message=3.8.10&color=green?style=plastic&logo=Python)](https://www.python.org/) -[![Bootstrap](https://img.shields.io/badge/Bootstrap-v5.0-blueviolet?style=plastic&logo=Bootstrap)](https://getbootstrap.com) -[![version](https://img.shields.io/badge/version-3.1.1-orange?style=plastic&logo=GitHub)](https://github.com/BU-ISCIII/iskylims.git) - -The introduction of massive sequencing (MS) in genomics facilities has meant an exponential growth in data generation, requiring a precise tracking system, from library preparation to fastq file generation, analysis and delivery to the researcher. Software designed to handle those tasks are called Laboratory Information Management Systems (LIMS), and its software has to be adapted to their own genomics laboratory particular needs. iSkyLIMS is born with the aim of helping with the wet laboratory tasks, and implementing a workflow that guides genomics labs on their activities from library preparation to data production, reducing potential errors associated to high throughput technology, and facilitating the quality control of the sequencing. Also, iSkyLIMS connects the wet lab with dry lab facilitating data analysis by bioinformaticians. - -![Image](img/iskylims_scheme.png) - -According to existent infrastructure sequencing is performed on an Illumina NextSeq instrument. Data is stored in NetApp mass storage device and fastq files are generated (bcl2fastq) on a Sun Grid Engine High Performance Computing cluster (SGE-HPC). -Application servers run web applications for bioinformatics analysis (GALAXY), the iSkyLIMS app, and host the MySQL information tier. iSkyLIMS WetLab workflow deals with sequencing run tracking and statistics. Run tracking passes through five states: "recorded” genomics user record the new sequencing run into the system, the process will wait till run is completed by the machine and data is transferred to the mass storage device; “Sample sheet sent” sample sheet file with the sequencing run information will be copied to the run folder for bcl2fastq process; “Processing data” run parameters files are processed and data is stored in the database; “Running stats” demultiplexing data generated in bcl2fastq process is processed and stored into the database, “Completed” all data is processed and stored successfully. Statistics per sample, per project, per run and per investigation are provided, as well as annual and monthly reports. iSkyLIMS DryLab workflow deals with bioinformatics services request and statistics. User request services that can be associated with a sequencing run. Stats and services tracking is provided. - -- [iSkyLIMS](#iskylims) - - [Get the code (required)](#get-the-code-required) - - [Choose your path](#choose-your-path) - - [Minimum requirements](#minimum-requirements) - - [Docker deployment](#docker-deployment) - - [Local test stack](#local-test-stack) - - [Production container](#production-container) - - [Persist logs/documents on the host](#persist-logsdocuments-on-the-host) - - [Apache reverse proxy (container) + Gunicorn](#apache-reverse-proxy-container--gunicorn) - - [Cron jobs inside the container](#cron-jobs-inside-the-container) - - [Manage containers after installation](#manage-containers-after-installation) - - [Upgrade docker deployment](#upgrade-docker-deployment) - - [Bare-metal deployment (Ubuntu/CentOS)](#bare-metal-deployment-ubuntucentos) - - [Install](#install) - - [Clone the repository](#clone-the-repository) - - [Prepare the database](#prepare-the-database) - - [Configure install\_settings.txt](#configure-install_settingstxt) - - [Run install.sh](#run-installsh) - - [Upgrade bare-metal deployment](#upgrade-bare-metal-deployment) - - [Common operations (Docker + bare-metal)](#common-operations-docker--bare-metal) - - [Database creation, users and grants](#database-creation-users-and-grants) - - [Backups](#backups) - - [Restore / rollback](#restore--rollback) - - [What to do if something fails](#what-to-do-if-something-fails) - - [Final configuration steps](#final-configuration-steps) - - [SAMBA configurarion](#samba-configurarion) - - [Email verification](#email-verification) - - [Developer notes](#developer-notes) - - [Django migrations workflow](#django-migrations-workflow) - - [Persistent host paths](#persistent-host-paths) - - [Configure Apache server](#configure-apache-server) - - [Verification of the installation](#verification-of-the-installation) - - [iSkyLIMS documentation](#iskylims-documentation) - -For any problems or bug reporting please post us an [issue](https://github.com/BU-ISCIII/iSkyLIMS/issues) +iSkyLIMS is a laboratory information management system for genomics facilities. +It tracks massive-sequencing work from library preparation and sequencing-run +registration through FASTQ generation, quality control, bioinformatics service +requests, analysis and delivery to researchers. + +## What is iSkyLIMS? + +iSkyLIMS connects wet-lab and dry-lab activities in one traceable workflow: + +- **WetLab** records projects, samples, library preparation, pools and + sequencing runs. It follows each run through registration, sample-sheet + delivery, data processing, statistics generation and completion, and exposes + reports by sample, project, run and investigation. +- **DryLab** manages bioinformatics service requests associated with sequencing + data, including requested analyses, status, files, resolution and delivery. +- **Clinic** and shared core modules provide the supporting application data, + permissions, configuration and APIs used by those workflows. + +![iSkyLIMS sequencing workflow](img/iskylims_scheme.png) + +## Infrastructure + +The supported deployment separates application runtime from institutional +services and persistent data: + +- The `relecov-iskylims` service runs Django under Gunicorn as an unprivileged user. +- The `apache` service is the public container reverse proxy and serves the + shared static files. Production TLS may terminate there or at the + institution's upstream proxy, according to the reviewed deployment. +- Production uses an external MySQL or MariaDB database. The disposable test + stack creates its own MySQL service. +- Production sequencing storage is an externally managed Samba share. The test + stack provides a disposable Samba service and can load fixtures and demo NGS + data through the installer. +- Documents and collected static files use persistent named volumes. Django + settings, application logs and Apache logs use the standardized protected + host paths documented below. +- Scheduled Django jobs run through Supercronic inside the application + container from the project's `CRONJOBS` setting. + +The same lifecycle supports Docker and Podman for local testing and production, +plus the reviewed Django bare-metal procedure. For issues, use the +[iSkyLIMS issue tracker](https://github.com/BU-ISCIII/iSkyLIMS/issues). + +- [What is iSkyLIMS?](#what-is-iskylims) +- [Infrastructure](#infrastructure) +- [Get the code (required)](#get-the-code-required) +- [Choose your path](#choose-your-path) +- [Minimum requirements](#minimum-requirements) +- [Docker deployment](#docker-deployment) + - [Local test stack](#local-test-stack) + - [Production container](#production-container) + - [Manage containers after installation](#manage-containers-after-installation) + - [Upgrade docker deployment](#upgrade-docker-deployment) +- [Bare-metal deployment (Ubuntu/CentOS)](#bare-metal-deployment-ubuntucentos) +- [Common operations (Docker + bare-metal)](#common-operations-docker--bare-metal) +- [Final configuration steps](#final-configuration-steps) + - [Configure Samba](#configure-samba) + - [Verify email](#verify-email) + - [Run the iSkyLIMS configuration tests](#run-the-iskylims-configuration-tests) +- [Developer notes](#developer-notes) +- [Application documentation](#application-documentation) ## Get the code (required) -All installation paths assume you already cloned the repository: - ```bash -git clone https://github.com/BU-ISCIII/iskylims.git iskylims +git clone https://github.com/BU-ISCIII/iSkyLIMS.git iskylims cd iskylims ``` -## Choose your path - -- **Docker (local test)**: spin up MySQL + Samba + iSkyLIMS with demo data to try the app quickly. -- **Docker (production container)**: deploy only the application container, pointing to your existing DB/Samba. -- **Bare-metal**: install or upgrade directly on Ubuntu/CentOS hosts with `install.sh`. - -## Minimum requirements - -Container deployment requirements: - -- Docker Engine + Docker Compose v2, or Podman + `podman-compose` -- git >= 2.34 to clone/update the repository -- Host MySQL/MariaDB, Apache, Python, and `lsb_release` are not required for container deployment -- For local test containers: MySQL and Samba are started as containers by `container_install.sh --test` -- For production containers: access to an external MySQL/MariaDB server and Samba share configured in the selected install config -- Host directories and permissions for logs, documents, and static files, as described in [Persist logs/documents on the host](#persist-logsdocuments-on-the-host) +For an orchestrated deployment, every external build context in the service +table must exist at the declared path relative to this checkout. -Bare-metal deployment requirements: +## Choose your path -- **sudo privileges** for dependency installation -- MySQL >= 8.0 or MariaDB > 10.4 -- Apache >= 2.4 -- git >= 2.34 -- Python >= 3.11 -- Local email sender configured -- Access to the Samba share where run folders live -- `lsb_release` package: - - RedHat/CentOS: `yum install redhat-lsb-core` - - Ubuntu: `apt install lsb-core lsb-release` +| Capability | Supported | Owner or command | +|---|---:|---| +| Docker local test | Yes | `container_install.sh --test --engine docker` | +| Podman local test | Yes | `container_install.sh --test --engine podman` | +| Docker production | Yes | `container_install.sh --engine docker` | +| Podman production | Yes | `container_install.sh --engine podman` | +| Bare metal | Profile-specific | See [Bare-metal deployment](#bare-metal-deployment-ubuntucentos) | +| Upgrade | Yes | `--action upgrade` | +| Permission repair | Yes | `--action fix-permissions` | +| Backup and restore | Yes | Operator-owned; follow [LEAME.md](LEAME.md) | -## Docker deployment +Services: -### Local test stack +| Service | Profile | Build context | Internal port | +|---|---|---|---:| +| `iskylims` | `django` | `.` | settings: `APP_PORT` | -Bring up a full test stack (database, Samba, app) plus fixtures and demo data: +- Django services build with an ephemeral settings secret, render protected host settings, and run controlled migration/bootstrap steps. -```bash -bash container_install.sh --test 2>&1 | tee test.log -``` +Selected add-ons: -Use `--engine podman` to run the same flow with Podman: +- Apache source configuration lives under `conf/apache/`; customize its virtual hosts and routes there. The installer renders final bind sources under `deployment/apache/`. +- The Samba add-on provides disposable NGS demo storage only in `--test` mode. -```bash -bash container_install.sh --test --engine podman 2>&1 | tee test.log -``` - -This uses `docker-compose.test.yml` by default. +## Minimum requirements -Defaults can be customised: +- Git and access to every declared build context. +- Docker Engine with Compose v2, or Podman with a Compose provider. +- Enough disk and memory for image builds and persistent application data. +- A protected production settings file for every application service. +- Production DNS, TLS termination, database, storage, email, identity, backup, + and monitoring services required by the selected profiles. -- `--demo_data /path/to/iskylims_demo_data.tar.gz` to reuse a local demo archive (otherwise it is downloaded). -- `--skip_demo_data` or `--skip_test_data` to avoid loading extra data. -- `--install_type` (`full` by default) and `--git_revision` to control the build. -- `--script` to run one or more Django migration scripts through `install.sh` (repeat the flag as needed). +Copy each service's `conf/docker_production_settings.txt` to a protected, +ignored file, set mode `0600`, and replace every `CHANGE_ME` value. The exact +meaning and security classification of settings is in +[`conf/INSTALL_SETTINGS.md`](conf/INSTALL_SETTINGS.md). -Example running a migration script during Docker install: +Create the ignored deployment settings directory and copy every production +template used by this deployment: ```bash -bash container_install.sh --test --script migrate_optional_values 2>&1 | tee test.log +install -d -m 0700 deployment/settings +install -m 0600 conf/docker_production_settings.txt deployment/settings/iskylims_production_settings.txt +install -m 0600 conf/apache/apache_production_settings.txt deployment/settings/apache_production_settings.txt +install -m 0600 conf/samba/samba_production_settings.txt deployment/settings/samba_production_settings.txt ``` -When the script finishes, open `http://localhost:8001` and follow the prompt to create the Django superuser. - -The image now includes the staged application tree under `${INSTALL_PATH}`. Test containers can therefore be recreated or restarted without rerunning the file installation step; only DB/bootstrap tasks are executed by `container_install.sh`. - -### Production container - -Deploy the iSkyLIMS container against external MySQL/Samba services: - -1. Copy and edit the production settings template: - - ```bash - cp conf/docker_production_settings.txt conf/my_prod_settings_iskylims.txt - # edit conf/my_prod_settings_iskylims.txt with your DB/Samba details - ``` - -2. Create the host directories used as bind mount sources and make them manageable by the account that will run `container_install.sh`. - - At minimum, this includes `/var/log/local/relecov-iskylims/apps`, `/var/log/local/relecov-iskylims/apache`, and any custom `APACHE_CONF_PATH` or `DJANGO_SETTINGS_PATH` parent directory set in `conf/my_prod_settings_iskylims.txt`. - - ```bash - sudo mkdir -p /var/log/local/relecov-iskylims/apps /var/log/local/relecov-iskylims/apache - sudo chown -R "$USER:$USER" /var/log/local/relecov-iskylims - ``` - - For rootless Podman, `container_install.sh` uses `podman unshare` to apply container UID/GID ownership where needed. For Docker, normal host permissions apply, so the script runner must be able to create and adjust the bind mount paths. - -3. Build and run in production mode (uses `docker-compose.prod.yml` by default): +Edit only the copies under `deployment/settings/`, replace every `CHANGE_ME`, +and keep their mode at `0600`. - ```bash - bash container_install.sh --install_conf conf/my_prod_settings_iskylims.txt 2>&1 | tee ./iskylims_docker_install_$(date +%Y%m%d_%H%M%S).log - ``` - - Use `--compose_file` to override the compose file or `--install_type`/`--git_revision` to change the build. - Add `--engine podman` to use Podman instead of Docker. - Tip: capture logs for troubleshooting: - - ```bash - bash container_install.sh --install_conf conf/my_prod_settings_iskylims.txt 2>&1 | tee ./iskylims_docker_install_$(date +%Y%m%d_%H%M%S).log - ``` - -4. If this is a fresh install, create the Django superuser when prompted and complete the Samba configuration in the UI. - -Production images now bake the staged iSkyLIMS application into the image itself. Host reboots or container recreation no longer require rerunning the app installation step; `container_install.sh` only performs runtime bootstrap tasks such as migrations, fixture refreshes, optional scripts, superuser creation on first install, and `collectstatic`. - -Container build/runtime values are configured in the selected install config, not by exporting shell variables. Edit these fields in `conf/my_prod_settings_iskylims.txt` before running `container_install.sh`: - -- `INSTALL_PATH`: runtime install root used by the app container, static/documents mounts, and install scripts. Default: `/opt/iskylims`. -- `APACHE_CONF_PATH`: host directory used for Apache bind-mounted config files. Leave empty to use `${INSTALL_PATH}/conf` as the host bind source; set this to a writable host path for rootless or hardened deployments. -- `SERVER_STATUS_SERVER_NAME`: Apache virtual host used for `/server-status`. Leave empty to use `DNS_URL`. -- `SERVER_STATUS_ALIASES`: aliases accepted by the server-status virtual host. Default: `127.0.0.1 localhost`. -- `SERVER_STATUS_ALLOW_FROM`: clients allowed to access `/server-status`. Default: `127.0.0.1 localhost`. -- `APACHE_FORWARDED_PROTO` / `APACHE_FORWARDED_PORT`: forwarded request scheme and port sent by Apache. Production defaults: `https` and `443`. -- `DJANGO_SETTINGS_PATH`: host path used for the bind-mounted Django `settings.py`. Leave empty to use `${INSTALL_PATH}/iskylims/settings.py` as the host bind source. If the value is a directory, ends with `/`, or does not end with `.py`, `container_install.sh` appends `settings.py`. -- `APP_UID` / `APP_GID`: runtime UID/GID for the `iskylims` user inside the container. Default: `1212:1212`. -- `APP_SHELL`: shell assigned to the runtime user during image build. Default: `/sbin/nologin`. -- `APP_PORT`: internal Gunicorn bind port for the `app` service. Default: `8001`. -- `DJANGO_DEBUG`: Django debug flag passed to the production app container. Default: `false`; keep it disabled in production. -- `DB_CONN_MAX_AGE`: Django persistent DB connection lifetime in seconds. Default: `60`. -- `WEB_CONCURRENCY`: Gunicorn worker count. Default: `2`. -- `GUNICORN_THREADS`: threads per Gunicorn worker. Default: `2`. -- `GUNICORN_TIMEOUT`: Gunicorn request timeout in seconds. Default: `300`. -- `GUNICORN_KEEPALIVE`: Gunicorn keep-alive in seconds. Default: `5`. - -The standalone iSkyLIMS Apache container renders `APACHE_FORWARDED_PROTO` and `APACHE_FORWARDED_PORT` from this settings file. When iSkyLIMS runs inside the integrated RELECOV stack, the shared `relecov_apache` proxy uses the corresponding values from `my_prod_settings_relecov.txt`. - -During production install/upgrade, `container_install.sh` writes `.env.prod.file` in the repository root. This file is ignored by git and is used by Compose for variable interpolation in `docker-compose.prod.yml`. It intentionally contains Compose/runtime metadata, not database or email passwords. - -Host directory and ownership preparation is described in [Persist logs/documents on the host](#persist-logsdocuments-on-the-host). - -#### Persist logs/documents on the host - -The production compose file uses `INSTALL_PATH` from the selected install config as the app container runtime root. Apache config and Django settings bind sources use `APACHE_CONF_PATH` and `DJANGO_SETTINGS_PATH` when set. - -Persistence layout: +## Docker deployment -- `/var/log/local/relecov-iskylims/apps` -> `${INSTALL_PATH}/logs` inside the `app` container -- `/var/log/local/relecov-iskylims/apache` -> `/var/log/httpd` inside the `apache` container -- `${APACHE_CONF_PATH:-${INSTALL_PATH}/conf}/iskylims_apache_reverse_proxy.conf` -> `/etc/httpd/conf.d/iskylims.conf` inside the `apache` container -- `${APACHE_CONF_PATH:-${INSTALL_PATH}/conf}/iskylims_apache_logs.conf` -> `/etc/httpd/conf.d/logformat.conf` inside the `apache` container -- `${APACHE_CONF_PATH:-${INSTALL_PATH}/conf}/iskylims_apache_server-status.conf` -> `/etc/httpd/conf.d/server-status.conf` inside the `apache` container -- `${DJANGO_SETTINGS_PATH:-${INSTALL_PATH}/iskylims/settings.py}` -> `${INSTALL_PATH}/iskylims/settings.py` inside the `app` container -- `iskylims_documents` named volume -> `${INSTALL_PATH}/documents` -- `iskylims_static` named volume -> `${INSTALL_PATH}/static` +Both engines use the same lifecycle and Compose files. Do not invoke Compose +directly for the first install or an upgrade: the installer also renders +configuration, prepares permissions, waits for readiness, and runs bootstrap. -If you override the compose file, ensure these mounts exist to keep logs and documents persistent. +### Local test stack -Create host directories before the first deployment: +Docker: ```bash -sudo mkdir -p /var/log/local/relecov-iskylims/apps -sudo mkdir -p /var/log/local/relecov-iskylims/apache -sudo mkdir -p -sudo mkdir -p -sudo chown -R : /var/log/local/relecov-iskylims/apps +bash container_install.sh --test --action install --engine docker \ + --git_revision current ``` -For hardened/rootless Podman hosts, run the host preparation script as the same -user that starts the containers. The script pre-creates Apache log files, fixes -rootless Podman ownership for the UBI httpd user, and applies SELinux container -labels when SELinux is enabled: +Podman: ```bash -bash hardening.sh +bash container_install.sh --test --action install --engine podman \ + --git_revision current ``` -If an administrator runs it as root, set `PODMAN_USER` to the user that starts -the rootless containers: +Test settings and test services are disposable. Verify either deployment with: ```bash -PODMAN_USER=bioinfo bash hardening.sh +bash scripts/smoke_test.sh --test --engine docker +# or: bash scripts/smoke_test.sh --test --engine podman ``` -#### Apache reverse proxy (container) + Gunicorn +Django test installation creates the disposable database declared by the test +Compose profile, waits for it, applies committed migrations, optionally loads +fixtures, runs selected data scripts, collects static files, and performs the +generated health checks. -For production, the `app` container runs `gunicorn` (not `manage.py runserver`) and the `apache` service in `docker-compose.prod.yml` acts as the reverse proxy. +Migration/data scripts are repeatable `django-extensions` runscript names. Use +`--script_before` for preparation before migrations and `--script` (an alias of +`--script_after`) for a transformation after migrations: -Static files: +```bash +bash container_install.sh --test --action install --engine docker \ + --script_before prepare_test_data \ + --script migrate_optional_values +``` -- The app collects static files into `${INSTALL_PATH}/static`. -- `docker-compose.prod.yml` shares that directory with the `apache` service through the named volume `iskylims_static`. -- The reverse proxy config serves `/static` directly from `${INSTALL_PATH}/static`. +Fresh installs automatically load `conf/first_install_tables.json` when the +application provides it. Use `--skip_tables` for an exceptional fresh install +without that fixture, or `--tables` to load it explicitly during an upgrade. -During `container_install.sh`, `conf/iskylims_apache_reverse_proxy.conf`, `conf/iskylims_apache_logs.conf`, and `conf/iskylims_apache_server-status.conf` are rendered and copied to `${APACHE_CONF_PATH}` on the host. If `APACHE_CONF_PATH` is empty, they are copied to `${INSTALL_PATH}/conf`. The reverse proxy `ServerName`, forwarded host, and access/error log file names are generated from `DNS_URL` in the selected install config. The `/server-status` virtual host uses `SERVER_STATUS_SERVER_NAME`, `SERVER_STATUS_ALIASES`, and `SERVER_STATUS_ALLOW_FROM`; by default it is restricted to localhost. +`--demo_data_map `, `--skip_demo_data`, `--skip_test_data`, and +`--skip_test_data_service ` are part of the standard interface. +`--demo_data ` remains a single-service compatibility option. A project +that supplies fixtures or demo files must set +`application_supports_test_data=true` and implement `load_test_deployment_data` +in its wrapper; otherwise explicit demo data is rejected. Production never +selects or loads demo data by default, and upgrades never reload it. -`container_install.sh` prepares a host-side Django `settings.py` bind source at `${DJANGO_SETTINGS_PATH}`, or at `${INSTALL_PATH}/iskylims/settings.py` when `DJANGO_SETTINGS_PATH` is empty. During the bootstrap step, `install.sh` updates that bind-mounted file from `conf/template_settings.txt` and the selected install config, preserving an existing `SECRET_KEY`. Runtime settings can then be edited and the container restarted without rebuilding the image. +For an automatic first administrator, set `CREATE_INITIAL_SUPERUSER=true` and +the `DJANGO_SUPERUSER_*` values in the selected test settings before install. +An existing account is never reset. Open the loopback URL using `APP_PORT` from +the rendered test environment, or `APACHE_PORT` when the Apache add-on is used. -If you need a different app container runtime root, set `INSTALL_PATH` in the install config file before running `container_install.sh`. If the runtime root is not writable on the host, set `APACHE_CONF_PATH` and `DJANGO_SETTINGS_PATH` to writable host paths in the install config file. +The Samba add-on supplies disposable test storage only. Applications may load +fixtures and demo files into it through `load_test_deployment_data`; production +continues to use the externally managed storage configured by the application. -`container_install.sh` creates `${APACHE_CONF_PATH:-${INSTALL_PATH}/conf}`, the parent directory for `${DJANGO_SETTINGS_PATH:-${INSTALL_PATH}/iskylims/settings.py}`, `/var/log/local/relecov-iskylims/apps`, and `/var/log/local/relecov-iskylims/apache` before `compose up`, copies the three Apache config files there, prepares the bind-mounted Django settings file if it does not exist, passes runtime values into Compose, and then runs `install.sh --bootstrap ...` inside the `app` container. The container image already contains the staged Django project and virtualenv under `${INSTALL_PATH}`; the bootstrap step updates settings, applies migrations, optional scripts/fixtures, and refreshes `${INSTALL_PATH}/static`, while the Apache container keeps using the host log path `/var/log/local/relecov-iskylims/apache`. +### Production container -SELinux note for pre-production and production: +Prepare the protected settings files and deploy a reviewed tag or commit. -- Ensure `/var/log/local/relecov-iskylims/apache` is writable by the container runtime and labeled for containers, for example `container_file_t`. -- If the host path is already labeled `container_file_t`, do not add `:Z` to the Apache log bind mount. `:Z` forces a relabel and may fail with `lsetxattr(... container_file_t ...): operation not permitted`. -- A quick check is: +Docker: ```bash -ls -ldZ /var/log/local/relecov-iskylims/apache +bash container_install.sh --action install --engine docker \ + --git_revision \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt ``` -- Expected example: +Podman: -```text -system_u:object_r:container_file_t:s0 +```bash +bash container_install.sh --action install --engine podman \ + --git_revision \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt ``` -- If Apache fails on startup with `ModSecurity: Failed to open debug log file: /var/log/httpd/modsec_debug.log`, remove any stale host file and recreate/restart the container. In practice, deleting `/var/log/local/relecov-iskylims/apache/modsec_debug.log` has been enough when the existing inode had bad permissions/label state. +The installer creates `.env.production.file` for later direct Compose +operations. It contains generated runtime values, including secrets copied +from the protected settings sources, so keep it mode `0600`, excluded from Git, +and inside the protected configuration backup. Neither the protected settings +nor this generated environment file is copied into image layers. -#### Cron jobs inside the container +#### Persist logs/documents on the host -Cron runs via `supercronic`, started by the container entrypoint script. The script reads Django `CRONJOBS` directly, writes `${INSTALL_PATH}/cron/iskylims`, and starts `supercronic` as the non-root app user. It does not call `manage.py crontab` or the system `crontab` command during container startup. +| Asset | Production location | Backup/rebuild policy | +|---|---|---| +| `iskylims` database | External production database | Database backup before migration | +| `iskylims` documents | `iskylims_documents` named volume | Volume backup | +| `iskylims` static | `iskylims_static` named volume | Replaceable through collectstatic | +| `iskylims` logs | Host bind configured by `HOST_LOG_PATH` in `iskylims_production_settings.txt` | Retain/rotate per institutional log policy | +| `iskylims` rendered settings | Host bind configured by `DJANGO_SETTINGS_PATH` in `iskylims_production_settings.txt` | Protected configuration backup | +| Apache logs | `/var/log/local/iskylims/apache` host bind | Retain/rotate per institutional log policy | +| Rendered Apache configuration | `deployment/apache/` in the deployment checkout | Rebuildable; preserve reviewed source configuration | +| Samba test data | `samba_test_data` named volume | Disposable test/demo files | -If you change `CRONJOBS`, rebuild or restart the container to regenerate the cron file. +The standard fixes application binds below `/srv/containers/bind/iskylims` +and logs below `/var/log/local/iskylims`. The operator must still record the +backup owner, retention, actual engine volume names, and restore-test evidence +for every non-rebuildable asset. Never treat a container writable layer as +persistent storage. -### Manage containers after installation +#### Reverse proxy and application server -After a production install, use the generated `.env.prod.file` whenever you run Compose directly. This keeps paths, UID/GID, ports, and Gunicorn settings aligned with the install config. +The selected profiles and add-ons define the internal application server and +proxy topology. Review public hostnames, TLS ownership, forwarded headers, +request limits, timeouts, health paths, and static/media routing together. -Docker Compose examples: +#### Scheduled jobs -```bash -docker compose --env-file .env.prod.file -f docker-compose.prod.yml ps -docker compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 app -docker compose --env-file .env.prod.file -f docker-compose.prod.yml restart app -docker compose --env-file .env.prod.file -f docker-compose.prod.yml up -d -``` +The application developer must list every scheduler/worker, whether a failed +job blocks a workflow, and how operators inspect and retry it. Do not add an +untracked host cron job when the application profile owns scheduling. + +### Manage containers after installation -Podman Compose examples: +Use the engine that performed the installation: ```bash -podman compose --env-file .env.prod.file -f docker-compose.prod.yml ps -podman compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 app -podman compose --env-file .env.prod.file -f docker-compose.prod.yml restart app -podman compose --env-file .env.prod.file -f docker-compose.prod.yml up -d +docker compose --env-file .env.production.file -f docker-compose.prod.yml ps +docker compose --env-file .env.production.file -f docker-compose.prod.yml logs --tail 200 +docker compose --env-file .env.production.file -f docker-compose.prod.yml restart ``` -If you edit container runtime values in the install config, rerun `container_install.sh --install_conf ` so `.env.prod.file` and the running containers are regenerated consistently. - -If containers were recreated manually, persistent volumes were restored, bind mount ownership changed, or `APP_UID` / `APP_GID` changed, repair permissions before running bootstrap tasks: - ```bash -bash container_install.sh --engine podman --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions +podman compose --env-file .env.production.file -f docker-compose.prod.yml ps +podman compose --env-file .env.production.file -f docker-compose.prod.yml logs --tail 200 +podman compose --env-file .env.production.file -f docker-compose.prod.yml restart ``` -This action does not rebuild images or run migrations. It refreshes `.env.prod.file` and fixes host bind mount permissions with `podman unshare` when needed. If `iskylims_app` is already running, it also fixes mounted app volumes from inside the container as root; otherwise, start the containers and rerun the same command to repair named volumes. For Docker, use `--engine docker`. - ### Upgrade docker deployment -Keep the same `APP_UID`/`APP_GID` values in the selected install config before running an upgrade. - -Re-deploy the application container against an existing production database: +After taking a consistent backup and reading the version-specific upgrade +notes: ```bash -bash container_install.sh --install_conf conf/my_prod_settings_iskylims.txt --action upgrade 2>&1 | tee ./iskylims_docker_install_$(date +%Y%m%d_%H%M%S).log +bash container_install.sh --action upgrade --engine podman \ + --git_revision \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt ``` -The upgrade path rebuilds/restarts the container and runs `install.sh --bootstrap upgrade --tables` inside the app container. The app files are already baked into the rebuilt image; the bootstrap phase applies migrations with `--fake-initial`, refreshes `conf/first_install_tables.json`, refreshes static files, and skips superuser/demo/test data loading. - -If the installed version requires data-migration scripts, follow the applicable [version-specific upgrade guide](docs/upgrades/README.md) instead of using only the generic command. +Replace `podman` with `docker` for a Docker-managed deployment. Stop on build, +readiness, bootstrap, migration, or smoke-test failure. See [LEAME.md](LEAME.md) +for the ordered production checklist and rollback decision. ## Bare-metal deployment (Ubuntu/CentOS) @@ -324,289 +285,381 @@ If the installed version requires data-migration scripts, follow the applicable #### Clone the repository -```bash -cd -git clone https://github.com/BU-ISCIII/iskylims.git iskylims -cd iskylims -``` +Use [Get the code (required)](#get-the-code-required) and check out the reviewed +revision. #### Prepare the database -Create the database and application user following [Database creation, users and grants](#database-creation-users-and-grants), then note DB host/port/user/password for `install_settings.txt`. +Provision the application database and least-privilege account outside the +installer. Confirm that the host can reach it before bootstrap. #### Configure install_settings.txt -```bash -cp conf/template_install_settings.txt install_settings.txt -nano install_settings.txt -``` - -Set your database, email, server IP/URL, and logging preferences in that file. +Start from `conf/docker_production_settings.txt`, but review all paths and +container-oriented defaults for the target host. Keep the resulting file +ignored and mode `0600`. #### Run install.sh -iSkyLIMS is installed to `/opt/iskylims` by default. The single `install.sh` script handles both dependencies and the app; choose what you need with `--install`: - -- `dep`: install system and Python dependencies (requires sudo). -- `app`: deploy iSkyLIMS code, update settings, run migrations, and collect static files (no sudo needed). -- `full`: run both stages in sequence. - -The staged/bootstrap split added for container images is internal. Bare-metal commands do not change: `--install` and `--upgrade` still run the complete dependency, application, and database workflow documented here. - -Examples: +The Django profile includes `install.sh` for application staging and bootstrap, +but system package, database, web-server, service-manager, TLS, and backup +provisioning remain host-specific. Bare-metal installation is supported only +after the application developer documents and tests those integrations. ```bash -# only software dependencies -sudo bash install.sh --install dep - -# only iSkyLIMS application -bash install.sh --install app --git_revision main --tables +# Stage application files and dependencies. +bash install.sh --stage install --git_revision current \ + --conf deployment/settings/iskylims_production_settings.txt -# dependencies + application -sudo bash install.sh --install full --git_revision main --tables +# Bootstrap the prepared runtime (settings, migrations and static files). +bash install.sh --bootstrap install \ + --conf deployment/settings/iskylims_production_settings.txt ``` -- Add `--tables` to load the initial fixtures on first-time installs, or `--skip_tables` if you want to skip them. -- Capture logs for troubleshooting with `tee`: +For upgrades, take a backup and replace both `install` actions with `upgrade`. +Do not use container-oriented paths or defaults on a bare-metal host without an +application-specific review. - ```bash - sudo bash install.sh --install full --git_revision main --tables 2>&1 | tee ./iskylims_install_$(date +%Y%m%d_%H%M%S).log - ``` +For a host-managed Apache 2.4 deployment, adapt the reviewed virtual host from +`conf/apache/` to the distribution path. The generated add-on files target the +container image, so do not copy them blindly without checking module names, +paths, runtime user, TLS ownership, and log locations. -- If Apache is managed elsewhere, skip the automatic restart with `--skip_apache_restart`. - -### Upgrade bare-metal deployment - -Run the backup steps in [Backups](#backups) first and back up the full installation folder (for example `/opt/iskylims`). Then refresh the repository and review the current installation settings template before upgrading. - -Update system and Python dependencies: +Ubuntu/Debian baseline: ```bash -sudo bash install.sh --upgrade dep 2>&1 | tee install_full.log +sudo cp /etc/apache2/sites-available/iskylims.conf +sudo a2enmod proxy proxy_http headers +sudo a2ensite iskylims.conf +sudo apache2ctl configtest +sudo systemctl reload apache2 ``` -Make sure the installation directory permissions allow the non-root step to write to `/opt/iskylims` (adapt your hardening script if paths changed). - -Upgrade the application code and database: +CentOS/RHEL baseline: ```bash -bash install.sh --upgrade app --git_revision main +sudo cp /etc/httpd/conf.d/iskylims.conf +sudo httpd -t +sudo systemctl reload httpd ``` -Upgrades regenerate migrations and apply them with `--fake-initial` so existing tables remain intact, matching the Docker workflow. +The reviewed virtual host must define the public `ServerName`, proxy to the +Django `APP_PORT`, serve the correct static/media paths, preserve forwarded +scheme/host headers, and use institutionally managed TLS and logs. -If the installed version requires data-migration scripts, follow the applicable [version-specific upgrade guide](docs/upgrades/README.md) instead of using only the generic command. +### Upgrade bare-metal deployment + +Follow the same staged lifecycle with `upgrade` only after a consistent backup +and review of the version-specific guide. ## Common operations (Docker + bare-metal) ### Database creation, users and grants -Run as MySQL root: +Production databases are externally managed unless the application documents a +different supported topology. Create a dedicated schema and least-privilege +account, verify connectivity from the application container, and keep DBA +commands and credentials outside this repository. -```sql -CREATE DATABASE IF NOT EXISTS iskylims CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +Connect as an authorized database administrator without putting the password +on the command line: -CREATE USER IF NOT EXISTS 'iskylims'@'%' IDENTIFIED BY 'djangopass'; -CREATE USER IF NOT EXISTS 'iskylims'@'localhost' IDENTIFIED BY 'djangopass'; - -GRANT ALL PRIVILEGES ON iskylims.* TO 'iskylims'@'%'; -GRANT ALL PRIVILEGES ON iskylims.* TO 'iskylims'@'localhost'; - -FLUSH PRIVILEGES; +```bash +DB_HOST='CHANGE_ME' +DB_PORT='3306' +DB_ADMIN='CHANGE_ME' +DB_NAME='CHANGE_ME' +DB_USER='CHANGE_ME' +mysql --host="$DB_HOST" --port="$DB_PORT" --user="$DB_ADMIN" --password ``` -Verification: +Create the application database and account. Replace every angle-bracket value; +restrict the account host further than `%` when the network topology permits. ```sql -SHOW GRANTS FOR 'iskylims'@'%'; +CREATE DATABASE `` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER ''@'%' IDENTIFIED BY ''; +GRANT ALL PRIVILEGES ON ``.* TO ''@'%'; +FLUSH PRIVILEGES; ``` -### Backups - -Database dump: +Verify the same endpoint and least-privilege credentials configured for the +application: ```bash -mysqldump -h -P -u iskylims -p iskylims > iskylims_$(date +%Y%m%d_%H%M%S).sql +mysql --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password \ + --database="$DB_NAME" --execute='SELECT 1;' ``` -Logs archive: +### Backups + +Back up every non-rebuildable row in the persistence table from one consistent +recovery point before installation or upgrade. Record the revision, image IDs, +settings files, and backup identifiers. ```bash -tar -czf iskylims_app_logs_$(date +%Y%m%d_%H%M%S).tgz -C /var/log/local/relecov-iskylims/apps . - -tar -czf iskylims_apache_logs_$(date +%Y%m%d_%H%M%S).tgz -C /var/log/local/relecov-iskylims/apache . +BACKUP_DIR="/srv/containers/backup/iskylims/$(date +%Y%m%d_%H%M%S)" +DOCUMENTS_VOLUME='CHANGE_ME' +DB_HOST='CHANGE_ME' +DB_PORT='3306' +DB_NAME='CHANGE_ME' +DB_USER='CHANGE_ME' +mkdir -p "$BACKUP_DIR" +git rev-parse HEAD > "$BACKUP_DIR/git-revision.txt" +cp .env.production.file "$BACKUP_DIR/" +cp deployment/settings/iskylims_production_settings.txt "$BACKUP_DIR/" +cp deployment/settings/apache_production_settings.txt "$BACKUP_DIR/" +cp deployment/settings/samba_production_settings.txt "$BACKUP_DIR/" +chmod -R go-rwx "$BACKUP_DIR" + +mysqldump --single-transaction --routines --triggers \ + --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password \ + "$DB_NAME" > "$BACKUP_DIR/database.sql" + +podman volume ls | grep 'iskylims' +podman volume export "$DOCUMENTS_VOLUME" > "$BACKUP_DIR/documents.tar" + +tar -C /srv/containers/bind -czf "$BACKUP_DIR/bind-mounts.tar.gz" iskylims +sha256sum "$BACKUP_DIR"/* > "$BACKUP_DIR/SHA256SUMS" ``` -Documents volume archive: +For Docker, archive a named volume through a temporary container after ensuring +the application is not writing to it: ```bash -docker run --rm -v iskylims_documents:/from -v "$PWD":/to alpine \ - tar -czf /to/iskylims_documents_$(date +%Y%m%d_%H%M%S).tgz -C /from . +docker run --rm \ + --volume "$DOCUMENTS_VOLUME":/data:ro \ + --volume "$BACKUP_DIR":/backup \ + alpine tar -C /data -cf /backup/documents.tar . ``` -With Podman, use the same command replacing `docker` with `podman`. - -Suggested order before upgrades: - -1. DB dump -2. Documents volume archive -3. Logs archive +The full ordered backup checklist, including logs and image metadata, is in +[LEAME.md](LEAME.md). ### Restore / rollback -Restore DB: +An image-only rollback is safe only when the previous application version +supports the current schema and persistent-file format. Otherwise stop writes, +restore the database and files from the same recovery point, deploy the recorded +compatible revision, and rerun all smoke tests. + +Compatible application-only rollback: ```bash -mysql -h -P -u iskylims -p iskylims < iskylims_YYYYMMDD_HHMMSS.sql +bash container_install.sh --action upgrade --engine podman \ + --git_revision \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt ``` -Restore documents volume: +Full restore when schema or persistent-file formats are incompatible: ```bash -docker run --rm -v iskylims_documents:/to -v "$PWD":/from alpine \ - sh -lc "cd /to && tar -xzf /from/iskylims_documents_YYYYMMDD_HHMMSS.tgz" +BACKUP_DIR='/srv/containers/backup/iskylims/CHANGE_ME' +DOCUMENTS_VOLUME='CHANGE_ME' +DB_HOST='CHANGE_ME' +DB_PORT='3306' +DB_NAME='CHANGE_ME' +DB_USER='CHANGE_ME' +podman compose --env-file .env.production.file -f docker-compose.prod.yml down +mysql --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password \ + "$DB_NAME" < "$BACKUP_DIR/database.sql" +podman volume import "$DOCUMENTS_VOLUME" "$BACKUP_DIR/documents.tar" +tar -C /srv/containers/bind -xzf "$BACKUP_DIR/bind-mounts.tar.gz" +install -d -m 0700 deployment/settings +install -m 0600 "$BACKUP_DIR/iskylims_production_settings.txt" deployment/settings/iskylims_production_settings.txt +install -m 0600 "$BACKUP_DIR/apache_production_settings.txt" deployment/settings/apache_production_settings.txt +install -m 0600 "$BACKUP_DIR/samba_production_settings.txt" deployment/settings/samba_production_settings.txt +bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt + ``` -With Podman, use the same command replacing `docker` with `podman`. +Then deploy the revision recorded in `git-revision.txt`, start the deployment, +and run the smoke test before reopening service. For Docker volume restoration, +reverse the temporary-container archive command by mounting the empty target +volume at `/data` and extracting `/backup/documents.tar` there. -Restore logs: +### What to do if something fails -```bash -mkdir -p /var/log/local/relecov-iskylims/apps -tar -xzf iskylims_app_logs_YYYYMMDD_HHMMSS.tgz -C /var/log/local/relecov-iskylims/apps +1. Preserve installer output, `compose ps`, image IDs, and service logs. +2. Test the direct application health endpoint and dependencies. +3. Test proxy routing, public DNS, and TLS after direct health succeeds. +4. Run permission repair for reviewed ownership or SELinux drift: -mkdir -p /var/log/local/relecov-iskylims/apache -tar -xzf iskylims_apache_logs_YYYYMMDD_HHMMSS.tgz -C /var/log/local/relecov-iskylims/apache -``` + ```bash + bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt + ``` + +5. Do not fake migrations, delete volumes, or rebuild from an unrecorded + revision as a first response. + +### Service-specific operational commands -Bare-metal full rollback example: +#### Django service `iskylims` ```bash -sudo rm -rf /opt/iskylims -sudo cp -r /home/dadmin/backup_prod/iSkyLIMS/ /opt/ -sudo /scripts/hardening.sh -mysql -u iskylims -p -h iskylims < /home/dadmin/backup_prod/bk_iSkyLIMS_YYYYMMDDHHMM.sql +# Logs and an interactive shell (replace podman with docker when applicable). +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + logs --tail 200 iskylims +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash + +# Rebuild static assets without running migrations. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash -lc \ + 'cd "$INSTALL_PATH" && source virtualenv/bin/activate && python manage.py collectstatic --noinput' + +# Inspect Django and migration state before deciding whether to recover. +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims bash -lc \ + 'cd "$INSTALL_PATH" && source virtualenv/bin/activate && python manage.py check --deploy && python manage.py showmigrations --plan' ``` -### What to do if something fails +For bootstrap recovery, fix the cause and rerun `container_install.sh` with the +same revision, protected configuration, and `--action install` or `upgrade`. +This safely recreates the temporary runtime configuration and repeats the +controlled migration/fixture/static lifecycle. Direct `manage.py migrate` is a +diagnostic last resort and must use the same backup and release procedure. + +#### Apache service -When install/upgrade fails, restore the previous state and retry with logs enabled. +```bash +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + logs --tail 200 iskylims-apache +podman compose --env-file .env.production.file -f docker-compose.prod.yml \ + exec iskylims-apache httpd -t + +APACHE_PORT='CHANGE_ME' +SERVER_STATUS_SERVER_NAME='localhost' +curl --fail --show-error \ + --header "Host: $SERVER_STATUS_SERVER_NAME" \ + "http://127.0.0.1:$APACHE_PORT/server-status?auto" +``` -Quick diagnostics: +Keep `SERVER_STATUS_ALLOW_FROM` limited to trusted diagnostic hosts. If SELinux +is enabled, inspect the persistent log bind and confirm a container-compatible +label before restarting: ```bash -# bare-metal -cd /opt/iskylims -python manage.py check - -# docker -docker compose --env-file .env.prod.file -f docker-compose.prod.yml ps -docker compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 app -# podman -podman compose --env-file .env.prod.file -f docker-compose.prod.yml ps -podman compose --env-file .env.prod.file -f docker-compose.prod.yml logs --tail 200 app +ls -ldZ /var/log/local/iskylims/apache ``` -If you suspect a corrupted image/build cache in Docker: +An Apache failure containing `ModSecurity: Failed to open debug log file` often +means the existing `modsec_debug.log` inode has stale ownership or labeling. +Preserve it for diagnosis, run `fix-permissions`, and restart Apache. If it must +be replaced, move it to a timestamped backup instead of deleting evidence: ```bash -docker compose --env-file .env.prod.file -f docker-compose.prod.yml build --no-cache app -docker compose --env-file .env.prod.file -f docker-compose.prod.yml up -d --force-recreate app +sudo mv /var/log/local/iskylims/apache/modsec_debug.log \ + /var/log/local/iskylims/apache/modsec_debug.log.blocked +bash container_install.sh --action fix-permissions --engine podman \ + --install_conf_map iskylims,deployment/settings/iskylims_production_settings.txt --install_conf_map apache,deployment/settings/apache_production_settings.txt --install_conf_map samba,deployment/settings/samba_production_settings.txt +podman compose --env-file .env.production.file -f docker-compose.prod.yml restart iskylims-apache ``` ## Final configuration steps -### SAMBA configurarion +Sign in with the administrator account after the first installation. The +installer creates that account only when `CREATE_INITIAL_SUPERUSER=true` and +the protected `DJANGO_SUPERUSER_*` settings are provided. -- Login with admin account. -- Go to Massive sequencing -![go_to_wetlab](img/got_to_wetlab.png){width:50px} -- Go to Configuration -> Samba configuration -- Fill the form with the appropiate params for the samba shared folder: -![samba form](img/samba_form.png) +### Configure Samba -### Email verification +iSkyLIMS stores the connection to the institutional sequencing-data share in +its application configuration. The Samba container included in the test stack +is disposable; production must use the reviewed external share. -- Go to Massive sequencing -- Go to Configuration -> Email configuration -- Fill the form with the needed params for your email configuration and try to send a test email. +1. Open **Massive sequencing** from the iSkyLIMS home page. -## Developer notes - -### Django migrations workflow + ![Open the WetLab module](img/got_to_wetlab.png) -Migrations are committed to the repo. Do not run `makemigrations` during install/upgrade. +2. Go to **Configuration → Samba configuration**. +3. Enter the server/share, credentials and paths for the sequencing run + folders. Use the disposable `SAMBA_USER` and `SAMBA_PASSWORD` only in the + local test deployment. +4. Save the configuration and verify the connection before enabling scheduled + run discovery. -Baseline + upgrade flow for new releases: + ![iSkyLIMS Samba configuration form](img/samba_form.png) -1. Generate baseline migrations from the last stable tag (example 3.0.0). -2. Commit the baseline migrations. -3. Generate new migrations on `develop` for schema changes and commit them. -4. Upgrades run `migrate --fake-initial` once to align existing tables, then `migrate` to apply the new migration files. +### Verify email -### Persistent host paths +1. Open **Massive sequencing → Configuration → Email configuration**. +2. Confirm the sender and recipients expected by the laboratory workflow. +3. Send a test message and verify delivery. Do not approve production while + application email fails, even when the SMTP connection itself succeeds. -See [Persist logs/documents on the host](#persist-logsdocuments-on-the-host) in the production deployment section. +### Run the iSkyLIMS configuration tests -### Configure Apache server +After every clean installation, upgrade, restore or Samba/email change: -These steps apply to bare-metal Apache installations. Docker production deployments use the `apache` container described above and do not require copying configs into `/etc/apache2` or `/etc/httpd`. +1. Run the generated deployment smoke test: -Copy the apache configuration file according to your distribution inside the apache configuration directory and rename it to `iskylims.conf`. + ```bash + bash scripts/smoke_test.sh --engine podman + ``` -Typical config locations: + Replace `podman` with `docker` for a Docker-managed deployment. -- Ubuntu/Debian: `/etc/apache2/sites-available/iskylims.conf` (enable with `a2ensite`) -- CentOS/RHEL: `/etc/httpd/conf.d/iskylims.conf` +2. Sign in as an administrator and open + `/wetlab/configurationTest/`. For the institutional + deployment, the current endpoint is + [iSkyLIMS configuration test](https://iskylims.isciii.es/wetlab/configurationTest/). +3. Submit the configuration test and inspect every result tab. +4. Confirm the database and Samba checks succeed. +5. Run the available checks for each configured sequencing platform, including + MiSeq, NextSeq and NovaSeq where enabled. +6. Confirm a representative WetLab read workflow and record the result with the + deployed revision. -Suggested steps (host Apache as reverse proxy): - -1. Copy the example config: +## Developer notes - ```bash - sudo cp conf/iskylims_apache_reverse_proxy.conf /etc/apache2/sites-available/iskylims.conf - # CentOS/RHEL: - # sudo cp conf/iskylims_apache_reverse_proxy.conf /etc/httpd/conf.d/iskylims.conf - ``` +### Shared container installer library -2. Edit the config: +`container_install.sh` sources the vendored files under +`deployment/lib/container/`. Do not edit those copies. Check or update them +from the standards repository with `scaffold.py check-lib` or `sync-lib`. - - Set `ServerName` - - Ensure `ProxyPass` points to `http://localhost:8001/` - - Ensure `Alias /static/ /opt/iskylims/static/` +### Schema migration workflow -3. Create the static folder on the host: +Django migrations MUST be generated, reviewed, tested, and committed with the +release. Installation and production upgrade run `migrate --noinput`; they +MUST NOT run `makemigrations` or silently manufacture schema history. - ```bash - sudo mkdir -p /opt/iskylims/static - ``` +For a legacy application entering the standard: -4. Enable required modules (Ubuntu/Debian): +1. Generate and commit baseline migrations from the last supported stable tag. +2. Generate and commit new migrations for later model changes. +3. Verify the committed migration history matches the supported production + database before deploying it. +4. Put ordered data transformations in version-specific upgrade guides and run + them through `--script_before`, `--script_after`, or `--script`. +5. Verify `showmigrations --plan` has no unapplied entries after bootstrap. - ```bash - sudo a2enmod proxy proxy_http headers - sudo a2ensite iskylims.conf - ``` +Never use `--fake` to conceal a failed or partially applied migration. New +installations and upgrades use the committed migration graph. -5. Reload Apache: +### Persistent host paths - ```bash - sudo systemctl reload apache2 - # CentOS/RHEL: - # sudo systemctl reload httpd - ``` +Keep source checkouts, protected configuration, bind mounts, engine-managed +volumes, logs, and backups separate. For rootless Podman, run the installer as +the same unprivileged account every time and use `fix-permissions` instead of +manually changing engine storage. ### Verification of the installation -Open the navigator and type "localhost" or the "server local IP" and check that iSkyLIMs is running. - -You can also check some of the functionality, while also checking samba and database connections using: +```bash +bash scripts/smoke_test.sh --engine podman +``` -- Go to [configuration test](https://iskylims.isciii.es/wetlab/configurationTest/) -- Click submit -- Check all tabs so every connectin is successful. -- Run the 3 tests for each sequencing machine: MiSeq, NextSeq and NovaSeq. +Complete the application-specific Samba, email and sequencing checks in +[Final configuration steps](#final-configuration-steps) after the generated +baseline succeeds. -## iSkyLIMS documentation +## Application documentation -iSkyLIMS documentation is available at [https://iskylims.readthedocs.io/en/latest](https://iskylims.readthedocs.io/en/latest) +- [User and administrator documentation](https://iskylims.readthedocs.io/en/latest/) +- [Version-specific upgrade guides](docs/upgrades/README.md) +- [Issue tracker](https://github.com/BU-ISCIII/iSkyLIMS/issues) diff --git a/conf/INSTALL_SETTINGS.md b/conf/INSTALL_SETTINGS.md new file mode 100644 index 000000000..4de3e9fb1 --- /dev/null +++ b/conf/INSTALL_SETTINGS.md @@ -0,0 +1,124 @@ +# Installation settings for iSkyLIMS + +`docker_test_settings.txt` contains disposable local defaults. +`docker_production_settings.txt` is a template and MUST NOT contain real +production secrets. Operators copy it to an ignored, permission-restricted file. + +Values already rendered from `project.json` (application name, module, Python +version and default paths) provide a runnable baseline. Operators customize +environment-dependent values. Application developers add domain settings to +both settings templates, this matrix, and `template_settings.py`; adding an +undocumented environment variable alone does not configure Django. + +## Application and filesystem + +| Variable | Required | Secret | Meaning | +|---|---:|---:|---| +| `REPO_PATH` | yes | no | Staged application source inside the image/container | +| `INSTALL_PATH` | yes | no | Application runtime root inside the container | +| `PROJECT_MODULE` | generated | no | Django package declared by `project.json`; do not customize independently | +| `PYTHON_BIN_PATH` | yes | no | Python used to create the virtual environment | +| `REQUIRED_MODULES` | application | no | Import checks required before bootstrap | +| `MIGRATION_MODULES` | application | no | Modules whose committed migrations are applied | +| `APP_UID`, `APP_GID` | yes | no | Runtime identity and rootless volume ownership | +| `APP_SHELL` | yes | no | Runtime account shell; normally `/sbin/nologin` in production | +| `APP_PORT` | yes | no | Internal Gunicorn and host-loopback port | +| `HOST_LOG_PATH` | production | no | Persistent application logs on the host | +| `DJANGO_SETTINGS_PATH` | production | no | Protected rendered `settings.py` bind source on the host | + +## Database + +| Variable | Required | Secret | Meaning | +|---|---:|---:|---| +| `DB_HOST`, `DB_PORT` | yes | no | MySQL endpoint reachable by the application | +| `DB_NAME`, `DB_USER` | yes | no | Application schema and least-privilege user | +| `DB_PASSWORD` | yes | yes | Application database password | +| `DB_ROOT_PASSWORD` | test only | yes | Root password for disposable Compose MySQL | + +Production uses an external database. The production Compose file deliberately +contains no database service and publishes no database port. When that database +runs on the container host, use `host.docker.internal` with either Docker or +Podman; the production service maps it to the host gateway. + +## Django and HTTP + +| Variable | Required | Secret | Meaning | +|---|---:|---:|---| +| `DJANGO_DEBUG` | yes | no | MUST be `false` in production | +| `DJANGO_SECRET_KEY` | yes | yes | Stable Django signing key; preserve on upgrade | +| `DJANGO_ALLOWED_HOSTS` | yes | no | Exact production hostnames | +| `DJANGO_CSRF_TRUSTED_ORIGINS` | production | no | Exact HTTPS origins | +| `DB_CONN_MAX_AGE` | yes | no | Django persistent database connection lifetime | +| `WEB_CONCURRENCY` | production | no | Gunicorn workers | +| `GUNICORN_THREADS` | production | no | Threads per worker | +| `GUNICORN_TIMEOUT` | production | no | Request timeout in seconds | +| `GUNICORN_KEEPALIVE` | production | no | Keep-alive time in seconds | +| `APP_START_WAIT_TIMEOUT_SECONDS` | yes | no | Maximum wait for staged application files | +| `LOG_TYPE`, `LOG_PATH` | application | no | Application logging backend and optional location | + +## Email + +`EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, and +`EMAIL_USE_TLS` configure SMTP. The password is secret; the remaining values +are operational unless the username is sensitive locally. Email settings are +required when the application sends operational or account messages. Document +whether failed email blocks the user workflow and add an SMTP test to +production acceptance. + +## Initial administrator + +`CREATE_INITIAL_SUPERUSER=true` creates the first Django administrator only +during `--bootstrap install`. Set `DJANGO_SUPERUSER_USERNAME`, optional +`DJANGO_SUPERUSER_EMAIL`, and secret `DJANGO_SUPERUSER_PASSWORD` in the +protected settings file. Bootstrap retries never reset an existing account. + +## Project-specific settings + +Add every application setting here before declaring installation complete. +Classify secrets and identify cross-service values that must match an identity +provider, proxy, worker, or frontend. + +Developer review checklist: + +- map every project-specific token in `template_settings.py`; +- provide safe disposable test values and `CHANGE_ME` production placeholders; +- state validation rules, owner, restart/rebuild impact, and secret status; +- add acceptance checks for email, identity, storage, workers, and scheduled + jobs used by real workflows. + +## Selected infrastructure add-ons + +Add-ons use independent settings below `conf//`. For production, copy +the required add-on templates to protected files and pass them through the same +repeatable `--install_conf_map ,` option used by application +services. + +### Apache + +`APACHE_LOG_PATH`, `APACHE_BIND_HOST`, `APACHE_PORT`, +`APACHE_FORWARDED_PROTO`, `APACHE_FORWARDED_PORT`, and +`APACHE_LIMIT_REQUEST_BODY` configure the rendered proxy. They are +operational values, not Django or React application settings. + +`APACHE_SERVER_NAME` is the host name handled by the baseline VirtualHost. +`APACHE_UPSTREAM_SERVICE` defaults to `ADDONS.apache.CONFIG_SERVICE`, while +`APACHE_UPSTREAM_PORT` defaults to that service's `APP_PORT`. +`APACHE_PROXY_TIMEOUT` defaults to its `GUNICORN_TIMEOUT` (or 120 seconds), and +`APACHE_LOG_STEM` defaults to a filename-safe form of `APACHE_SERVER_NAME`. +Leave those four derived values empty unless the proxy route needs an override. + +`SERVER_STATUS_SERVER_NAME`, `SERVER_STATUS_ALIASES`, and +`SERVER_STATUS_ALLOW_FROM` configure the restricted Apache status endpoint. +Keep its allow-list limited to trusted diagnostic hosts. + +Edit the source files under `conf/apache/` to define the application's virtual +hosts, routes, and aliases. During installation they are rendered with the +protected deployment environment into `deployment/apache/`; only those final +files are bind-mounted. `APACHE_LOG_PATH` is the writable persistent host log +source. + + +### Samba test data + +`SAMBA_USER` and `SAMBA_PASSWORD` configure the disposable Samba service used +only by the test Compose profile. The add-on creates no production service. diff --git a/conf/iskylims_apache_logs.conf b/conf/apache/00-logs.conf similarity index 100% rename from conf/iskylims_apache_logs.conf rename to conf/apache/00-logs.conf diff --git a/conf/apache/01-reverse-proxy.conf b/conf/apache/01-reverse-proxy.conf new file mode 100644 index 000000000..888b56332 --- /dev/null +++ b/conf/apache/01-reverse-proxy.conf @@ -0,0 +1,104 @@ +# Apache reverse proxy source configuration for iskylims. +# +# This file is copied to conf/apache/01-reverse-proxy.conf by the scaffold. +# Customize its VirtualHost and route blocks in the application repository. +# container_install.sh replaces every shell-style uppercase variable from the protected +# deployment environment and writes deployment/apache/01-reverse-proxy.conf. +# +# This baseline routes the service selected by ADDONS.apache.CONFIG_SERVICE. +# For a single application, or for that selected default service, use the short +# values INSTALL_PATH, APACHE_UPSTREAM_SERVICE, and APACHE_UPSTREAM_PORT below. +# INSTALL_PATH is read from the selected service's production/test settings. +# +# In a multi-application deployment, every service already exports its own +# prefix: api provides API_APP_PORT and API_INSTALL_PATH, web provides +# WEB_APP_PORT and WEB_INSTALL_PATH, and so on. Use those service-owned values +# directly in additional VirtualHost/path blocks; do not create APACHE_API_* or +# APACHE_WEB_* copies. +# +# MULTI-APPLICATION ROUTING SUMMARY +# 1. Put static/document ProxyPass exclusions and Alias directives before a +# matching catch-all ProxyPass. +# 2. A Django service named api uses API_INSTALL_PATH for mounted assets and +# API_APP_PORT for proxying to http://api:/. +# 3. A Django service named analysis similarly uses ANALYSIS_INSTALL_PATH and +# ANALYSIS_APP_PORT. Each Django service has its own mounted asset paths. +# 4. A React service named web normally needs only a proxy to +# http://web:/ because Nginx serves its compiled assets. +# 5. Use distinct URL prefixes or VirtualHosts so catch-all routes do not +# overlap. Complete copyable examples live in the Apache add-on README. + + ServerName ${APACHE_SERVER_NAME} + + ProxyRequests Off + ProxyPreserveHost On + AllowEncodedSlashes NoDecode + RequestHeader set X-Forwarded-Proto "${APACHE_FORWARDED_PROTO}" + RequestHeader set X-Forwarded-Port "${APACHE_FORWARDED_PORT}" + RequestHeader set X-Forwarded-Host "${APACHE_SERVER_NAME}" + + # Keep Apache and ModSecurity request-body limits aligned. + LimitRequestBody ${APACHE_LIMIT_REQUEST_BODY} + + SecRequestBodyLimit ${APACHE_LIMIT_REQUEST_BODY} + SecRequestBodyNoFilesLimit ${APACHE_LIMIT_REQUEST_BODY} + + + # These exclusions must precede the catch-all ProxyPass. The installer + # INSTALL_PATH comes from CONFIG_SERVICE. Compose mounts the selected + # service's static and documents sources at these exact container paths. + ProxyPass /static/ ! + Alias /static/ "${INSTALL_PATH}/static/" + + Require all granted + + + ProxyPass /documents/ ! + Alias /documents/ "${INSTALL_PATH}/documents/" + + Require all granted + + + # Default single-service route. Change or duplicate this block when the + # deployment exposes multiple applications or path prefixes. Prefixed + # values such as API_INSTALL_PATH remain available for additional blocks. + ProxyPass / http://${APACHE_UPSTREAM_SERVICE}:${APACHE_UPSTREAM_PORT}/ + ProxyPassReverse / http://${APACHE_UPSTREAM_SERVICE}:${APACHE_UPSTREAM_PORT}/ + ProxyTimeout ${APACHE_PROXY_TIMEOUT} + TimeOut ${APACHE_PROXY_TIMEOUT} + + CustomLog logs/${APACHE_LOG_STEM}-apache.access.log combined env=!forwarded + CustomLog logs/${APACHE_LOG_STEM}-apache.access.log proxy env=forwarded + ErrorLog logs/${APACHE_LOG_STEM}-apache.error.log + + +# OPTIONAL SECOND DNS VIRTUAL HOST +# Copy this commented example into an application's source configuration and +# replace every angle-bracket value. Give it the same forwarded-header, +# request-limit, timeout, and per-host logging contract as the default vhost. +# +# +# ServerName +# +# ProxyRequests Off +# ProxyPreserveHost On +# AllowEncodedSlashes NoDecode +# RequestHeader set X-Forwarded-Proto "" +# RequestHeader set X-Forwarded-Port "" +# RequestHeader set X-Forwarded-Host "" +# +# LimitRequestBody +# +# SecRequestBodyLimit +# SecRequestBodyNoFilesLimit +# +# +# ProxyPass / http://:/ +# ProxyPassReverse / http://:/ +# ProxyTimeout +# TimeOut +# +# CustomLog logs/-apache.access.log combined env=!forwarded +# CustomLog logs/-apache.access.log proxy env=forwarded +# ErrorLog logs/-apache.error.log +# diff --git a/conf/iskylims_apache_server-status.conf b/conf/apache/02-server-status.conf similarity index 69% rename from conf/iskylims_apache_server-status.conf rename to conf/apache/02-server-status.conf index 7d0969292..d03211e72 100644 --- a/conf/iskylims_apache_server-status.conf +++ b/conf/apache/02-server-status.conf @@ -3,12 +3,12 @@ ExtendedStatus On - ServerName __SERVER_STATUS_SERVER_NAME__ - ServerAlias __SERVER_STATUS_ALIASES__ + ServerName ${SERVER_STATUS_SERVER_NAME} + ServerAlias ${SERVER_STATUS_ALIASES} SetHandler server-status Order deny,allow Deny from all - Allow from __SERVER_STATUS_ALLOW_FROM__ + Allow from ${SERVER_STATUS_ALLOW_FROM} diff --git a/conf/apache/apache_production_settings.txt b/conf/apache/apache_production_settings.txt new file mode 100644 index 000000000..9f61a455c --- /dev/null +++ b/conf/apache/apache_production_settings.txt @@ -0,0 +1,77 @@ +# Apache add-on +# Used only when ADDONS.apache selects this application as CONFIG_SERVICE. + +# Repository-owned sources live under /conf/apache. The installer +# renders them to /deployment/apache and mounts those final files; +# APACHE_CONF_PATH is not used by the standard lifecycle. Keep it empty unless +# legacy application code explicitly consumes a different host directory. +# Example legacy override: APACHE_CONF_PATH='/srv/containers/bind/apache-conf' +APACHE_CONF_PATH='' + +# Persistent host directory mounted as the Apache log destination. +# Example: /var/log/local/iskylims/apache +APACHE_LOG_PATH='/var/log/local/iskylims/apache' + +# Public host interface and port. Use 127.0.0.1 when another host reverse proxy +# is the only component that should reach this Apache container. +# Examples: APACHE_BIND_HOST='0.0.0.0', APACHE_PORT='8080'. +APACHE_BIND_HOST='0.0.0.0' +APACHE_PORT='8080' + +# Public host name handled by the default VirtualHost. Use localhost for a +# workstation deployment or the externally visible DNS name in production. +# Example: api.example.org +APACHE_SERVER_NAME='CHANGE_ME_DNS_NAME' + +# Compose service receiving proxied requests. Leave empty to use the service +# selected by ADDONS.apache.CONFIG_SERVICE (normally app for a single service). +# Example override: app +APACHE_UPSTREAM_SERVICE='' + +# Internal application port used by ProxyPass. Leave empty to use APP_PORT from +# the selected service's production settings. Example override: 8001 +APACHE_UPSTREAM_PORT='' + +# Maximum proxy request duration in seconds. Leave empty to use the selected +# service's GUNICORN_TIMEOUT, falling back to 120 seconds when it is undefined. +# Example override: 300 +APACHE_PROXY_TIMEOUT='' + +# Prefix for the generated -apache.access.log and +# -apache.error.log files. Leave empty to derive it from +# APACHE_SERVER_NAME, with URL schemes, paths and ports removed. +# Example override: mepram-api +APACHE_LOG_STEM='' + +# Server-status stays restricted by default. Use a local-only diagnostic name; +# aliases and allow-list accept Apache-compatible values. +# Example diagnostic host: localhost +SERVER_STATUS_SERVER_NAME='localhost' +# Space-separated aliases. Example: 127.0.0.1 localhost +SERVER_STATUS_ALIASES='127.0.0.1 localhost' +# Apache Require ip/host values. Example: 127.0.0.1 localhost +SERVER_STATUS_ALLOW_FROM='127.0.0.1 localhost' + +# Forwarded scheme received by Django. Example: https +APACHE_FORWARDED_PROTO='https' +# Forwarded public port received by Django. Example: 443 +APACHE_FORWARDED_PORT='443' + +# 50 MiB. The reverse-proxy template applies this value to Apache and, when +# loaded, ModSecurity. Increase it only for endpoints that require larger data. +# Example: 52428800 (50 MiB) +APACHE_LIMIT_REQUEST_BODY='52428800' + +# Optional additional VirtualHosts are application-owned and are not generated +# automatically when another add-on (for example Keycloak) is selected. For +# every enabled extra vhost, define one uniquely prefixed set matching every +# variable referenced by its block in conf/apache/01-reverse-proxy.conf. +# Duplicate and rename this complete example for each additional public DNS: +# APACHE_SECOND_SERVER_NAME='CHANGE_ME_SECOND_DNS_NAME' +# APACHE_SECOND_UPSTREAM_SERVICE='CHANGE_ME_COMPOSE_SERVICE' +# APACHE_SECOND_UPSTREAM_PORT='CHANGE_ME_INTERNAL_PORT' +# APACHE_SECOND_PROXY_TIMEOUT='120' +# APACHE_SECOND_LOG_STEM='CHANGE_ME_LOG_STEM' +# APACHE_SECOND_FORWARDED_PROTO='https' +# APACHE_SECOND_FORWARDED_PORT='443' +# APACHE_SECOND_LIMIT_REQUEST_BODY='52428800' diff --git a/conf/apache/apache_test_settings.txt b/conf/apache/apache_test_settings.txt new file mode 100644 index 000000000..2ce900bda --- /dev/null +++ b/conf/apache/apache_test_settings.txt @@ -0,0 +1,52 @@ +# Apache add-on +# Used only when ADDONS.apache selects this application as CONFIG_SERVICE. + +# Sources under /conf/apache are rendered into deployment/apache. +# This compatibility value is unused unless application code consumes it. +APACHE_CONF_PATH='' + +# Disposable test logs and a public test endpoint. +APACHE_LOG_PATH='/tmp/iskylims/apache-logs' +APACHE_BIND_HOST='0.0.0.0' +APACHE_PORT='8081' + +# Host name handled by the local test VirtualHost. +APACHE_SERVER_NAME='localhost' + +# Compose service receiving proxied requests. Leave empty to use the service +# selected by ADDONS.apache.CONFIG_SERVICE (normally app for a single service). +APACHE_UPSTREAM_SERVICE='' + +# Internal application port used by ProxyPass. Leave empty to use APP_PORT from +# the selected service's test settings. +APACHE_UPSTREAM_PORT='' + +# Maximum proxy request duration in seconds. Leave empty to use the selected +# service's GUNICORN_TIMEOUT, falling back to 120 seconds when it is undefined. +APACHE_PROXY_TIMEOUT='' + +# Prefix for the generated -apache.access.log and +# -apache.error.log files. Leave empty to derive it from +# APACHE_SERVER_NAME, with URL schemes, paths and ports removed. +APACHE_LOG_STEM='' + +# Local server-status defaults. +SERVER_STATUS_SERVER_NAME='localhost' +SERVER_STATUS_ALIASES='127.0.0.1 localhost' +SERVER_STATUS_ALLOW_FROM='127.0.0.1 localhost' + +# Test proxy headers and request-body limit. +APACHE_FORWARDED_PROTO='http' +APACHE_FORWARDED_PORT='8081' +APACHE_LIMIT_REQUEST_BODY='52428800' + +# Extra test VirtualHosts follow the same explicit one-variable-set-per-vhost +# contract as production; selecting another add-on does not create these. +# APACHE_SECOND_SERVER_NAME='second.localhost' +# APACHE_SECOND_UPSTREAM_SERVICE='CHANGE_ME_COMPOSE_SERVICE' +# APACHE_SECOND_UPSTREAM_PORT='CHANGE_ME_INTERNAL_PORT' +# APACHE_SECOND_PROXY_TIMEOUT='120' +# APACHE_SECOND_LOG_STEM='second' +# APACHE_SECOND_FORWARDED_PROTO='http' +# APACHE_SECOND_FORWARDED_PORT='8081' +# APACHE_SECOND_LIMIT_REQUEST_BODY='52428800' diff --git a/conf/docker_production_settings.txt b/conf/docker_production_settings.txt index 8c311d643..8ff91158e 100644 --- a/conf/docker_production_settings.txt +++ b/conf/docker_production_settings.txt @@ -1,78 +1,97 @@ -### Production specific installation config -# Copy this file or edit the values below so they match the external -# infrastructure you are connecting to before building the image. +# Production configuration for this application service. +# Copy to a protected ignored file, chmod 0600, and replace every CHANGE_ME. +# The same file drives the ephemeral image build secret, host settings render, +# Compose environment and temporary bootstrap copy. -### Installation path and modules settings -# container path for installation +### Application layout and Django project +# Source repository staged in the image. Example: /srv/iskylims +REPO_PATH='/srv/iskylims' +# Final application runtime path. Example: /opt/iskylims INSTALL_PATH='/opt/iskylims' +# Python package containing settings.py, urls.py and wsgi.py. This is generated +# from project.json and normally should not be changed in this file. +PROJECT_MODULE='iskylims' +# Python executable used to create the application virtual environment. +PYTHON_BIN_PATH='python3.11' + +### Application-specific installation modules +# Optional space-separated Django applications consumed by installers that +# support dependency and migration module lists. Leave empty when +# the application install.sh does not implement the corresponding behavior. +# iSkyLIMS example: REQUIRED_MODULES='core drylab wetlab clinic django_utils' REQUIRED_MODULES='core drylab wetlab clinic django_utils' MIGRATION_MODULES='core drylab wetlab django_utils' -FAKEINITIAL_MODULES='django_utils iSkyLIMS_core iSkyLIMS_wetlab iSkyLIMS_drylab' - -### Container runtime settings -# Host directory for Apache bind-mounted configuration files. -# Leave empty to use ${INSTALL_PATH}/conf as the host bind source. -# Set this to a writable host path for rootless or hardened deployments. -# Example: APACHE_CONF_PATH='/srv/containers/bind/iskylims/iskylims_apache_conf' -APACHE_CONF_PATH='' - -# Apache server-status virtual host and access control. -# Leave SERVER_STATUS_SERVER_NAME empty to use DNS_URL. Normally use vm hostname -SERVER_STATUS_SERVER_NAME='' -SERVER_STATUS_ALIASES='127.0.0.1 localhost' -SERVER_STATUS_ALLOW_FROM='127.0.0.1 localhost' - -# Forwarded headers set by Apache in production. -APACHE_FORWARDED_PROTO='https' -APACHE_FORWARDED_PORT='443' - -# Host path for the bind-mounted Django settings.py. -# Leave empty to use ${INSTALL_PATH}/iskylims/settings.py as the host bind source. -# If this is a directory, ends with /, or does not end with .py, container_install.sh appends settings.py. -# Example: DJANGO_SETTINGS_PATH='/srv/containers/bind/iskylims/iskylims_django_setting/settings.py' -DJANGO_SETTINGS_PATH='' -# UID/GID for the non-root iskylims user inside the app container. +### Container runtime identity +# UID/GID must own every writable bind mount and named volume. APP_UID='1212' APP_GID='1212' - -# Shell assigned to the container runtime user during image build. +# Production services should normally use a non-login shell. APP_SHELL='/sbin/nologin' - -# Internal app port used by Gunicorn. +# Internal application port used by Gunicorn and the reverse proxy. APP_PORT='8001' -# Django production debug flag. Keep disabled in production. -DJANGO_DEBUG='false' -# Django persistent database connection lifetime in seconds. -DB_CONN_MAX_AGE='60' -# Gunicorn runtime tuning. -WEB_CONCURRENCY='2' -GUNICORN_THREADS='2' -GUNICORN_TIMEOUT='300' -GUNICORN_KEEPALIVE='5' +### Persistent host sources +# Documents use the Compose-managed iskylims_documents named volume. +# Application logs remain on the host and must be creatable by the installer. +# Example: /var/log/local/iskylims/apps +HOST_LOG_PATH='/var/log/local/iskylims/apps' +# Host bind source rendered before Compose starts. It must be a file path, not +# a directory; the parent directory is created and protected by the installer. +# Example: /srv/containers/bind/iskylims/settings/iskylims/settings.py +DJANGO_SETTINGS_PATH='/srv/containers/bind/iskylims/settings/iskylims/settings.py' -### (optional) Python installation path where pip and python executables are located -PYTHON_BIN_PATH='python3.11' +### External production database +# Use a host name or IP reachable from inside the application container. +# For a database running on the container host with either Docker or Podman, +# use host.docker.internal; the production Compose profile maps that alias. +DB_HOST='CHANGE_ME' +DB_PORT='3306' +DB_NAME='CHANGE_ME' +DB_USER='CHANGE_ME' +DB_PASSWORD='CHANGE_ME' -### Settings required to access database (external MySQL server) -DB_USER='django' -DB_PASS='djangopass' -DB_NAME='iskylims' -DB_SERVER_IP='host.docker.internal' # hostname or IP of the production DB (use host.docker.internal for host DB) -DB_PORT=3306 +### Django security and public URLs +# Production debug must remain disabled. +DJANGO_DEBUG='false' +# Preserve this value across upgrades; changing it invalidates signed data. +DJANGO_SECRET_KEY='CHANGE_ME' +# Comma-separated host names accepted by Django, without URL schemes. +DJANGO_ALLOWED_HOSTS='CHANGE_ME' +# Comma-separated complete HTTPS origins, including their schemes. +DJANGO_CSRF_TRUSTED_ORIGINS='https://CHANGE_ME' +# Persistent database connection lifetime in seconds. +DB_CONN_MAX_AGE='60' -### Settings required for sending emails -EMAIL_HOST_SERVER='host.docker.internal' +### Email +# SMTP endpoint reachable from the application container. +EMAIL_HOST='CHANGE_ME' EMAIL_PORT='25' -EMAIL_HOST_USER='bioinformatica@isciii.es' -EMAIL_HOST_PASSWORD='' +EMAIL_HOST_USER='CHANGE_ME' +EMAIL_HOST_PASSWORD='CHANGE_ME' +# Python boolean spelling is used because settings rendering writes this value +# directly into settings.py. EMAIL_USE_TLS='False' -### Settings required for accessing iSkyLIMS -LOCAL_SERVER_IP='*' -DNS_URL='*' +### Initial administrator (bootstrap install only) +# Set to true only when a fresh installation should create its first Django +# administrator. Keep the protected production settings file mode at 0600. +CREATE_INITIAL_SUPERUSER='true' +DJANGO_SUPERUSER_USERNAME='admin' +DJANGO_SUPERUSER_EMAIL='' +DJANGO_SUPERUSER_PASSWORD='CHANGE_ME' + +### Gunicorn and startup tuning +WEB_CONCURRENCY='2' +GUNICORN_THREADS='2' +GUNICORN_TIMEOUT='300' +GUNICORN_KEEPALIVE='5' +# Maximum time container_start.sh waits for staged application files. +APP_START_WAIT_TIMEOUT_SECONDS='100' -### Logs settings -LOG_TYPE="regular_folder" -LOG_PATH="" +### Application log policy compatibility +# regular_folder stores logs below INSTALL_PATH. symbolic_link links that +# directory to LOG_PATH, which must already exist and be writable. +LOG_TYPE='regular_folder' +# Example for symbolic_link: /var/log/local/iskylims/apps; otherwise empty. +LOG_PATH='' diff --git a/conf/docker_test_settings.txt b/conf/docker_test_settings.txt index 3349b17b8..11e4129c3 100644 --- a/conf/docker_test_settings.txt +++ b/conf/docker_test_settings.txt @@ -1,70 +1,63 @@ -### Installation path and modules settings +# Safe local/test values. Never use these credentials in production. + +### Application layout and Django project +REPO_PATH='/srv/iskylims' INSTALL_PATH='/opt/iskylims' +PROJECT_MODULE='iskylims' +PYTHON_BIN_PATH='python3.11' + +### Application-specific installation modules +# Optional lists used only when install.sh implements these operations. REQUIRED_MODULES='core drylab wetlab clinic django_utils' MIGRATION_MODULES='core drylab wetlab django_utils' -FAKEINITIAL_MODULES='django_utils iSkyLIMS_core iSkyLIMS_wetlab iSkyLIMS_drylab' -### Container runtime settings -# Host directory for Apache bind-mounted configuration files. -# Leave empty to use ${INSTALL_PATH}/conf as the host bind source. -# Set this to a writable host path for rootless or hardened deployments. -# Example: APACHE_CONF_PATH='/srv/containers/bind/iskylims_apache_conf' -APACHE_CONF_PATH='' -# Apache server-status virtual host and access control. -# Leave SERVER_STATUS_SERVER_NAME empty to use DNS_URL. -SERVER_STATUS_SERVER_NAME='' -SERVER_STATUS_ALIASES='127.0.0.1 localhost' -SERVER_STATUS_ALLOW_FROM='127.0.0.1 localhost' -# Forwarded headers set by the RELECOV Apache endpoint exposed on port 8081. -APACHE_FORWARDED_PROTO='http' -APACHE_FORWARDED_PORT='8081' -# Host path for the bind-mounted Django settings.py. -# Leave empty to use ${INSTALL_PATH}/iskylims/settings.py as the host bind source. -# If this is a directory, ends with /, or does not end with .py, container_install.sh appends settings.py. -# Example: DJANGO_SETTINGS_PATH='/srv/containers/bind/iskylims/iskylims_django_setting/settings.py' -DJANGO_SETTINGS_PATH='' -# UID/GID for the non-root iskylims user inside the app container. +### Container runtime identity APP_UID='1212' APP_GID='1212' -# Shell assigned to the container runtime user during image build. APP_SHELL='/bin/bash' -# Internal app port used by Django runserver/Gunicorn. -APP_PORT='8001' -# Django production debug flag. Keep disabled in production. -DJANGO_DEBUG='false' -# Django persistent database connection lifetime in seconds. -DB_CONN_MAX_AGE='60' -# Gunicorn runtime tuning. -WEB_CONCURRENCY='2' -GUNICORN_THREADS='2' -GUNICORN_TIMEOUT='300' -GUNICORN_KEEPALIVE='5' - -### (optional) Python installation path where pip and python executables are located -PYTHON_BIN_PATH='python3' # example: /opt/python/3.9.6/bin/python3 +APP_PORT='8201' -### Settings required to access database +### Disposable persistence paths +HOST_LOG_PATH='/tmp/iskylims/logs' +# Test images render settings during build and do not mount this host path. +DJANGO_SETTINGS_PATH='/tmp/iskylims/data/settings/iskylims/settings.py' +### Compose test database +DB_HOST='iskylims-db' +DB_PORT='3306' +DB_NAME='iskylims' DB_USER='django' -DB_PASS='djangopass' -DB_NAME='iskylims_docker' -DB_SERVER_IP='db' -DB_PORT=3306 - -### Settings required for sending emails +DB_PASSWORD='djangopass' +DB_ROOT_PASSWORD='root' + +### Django and local email defaults +DJANGO_DEBUG='true' +DJANGO_SECRET_KEY='test-only-change-me' +DJANGO_ALLOWED_HOSTS='*' +DJANGO_CSRF_TRUSTED_ORIGINS='http://localhost:8201,http://127.0.0.1:8201,http://0.0.0.0:8201' +DB_CONN_MAX_AGE='60' -EMAIL_HOST_SERVER='host.docker.internal' +### Local email defaults +EMAIL_HOST='host.docker.internal' EMAIL_PORT='25' EMAIL_HOST_USER='bioinformatica@isciii.es' EMAIL_HOST_PASSWORD='' EMAIL_USE_TLS='False' -### Settings required for accessing iSkyLIMS -# example: 172.0.0.1 -LOCAL_SERVER_IP='*' -# example: iskylims.isciii.es -DNS_URL='*' +### Initial administrator (bootstrap install only) +CREATE_INITIAL_SUPERUSER='true' +DJANGO_SUPERUSER_USERNAME='admin' +DJANGO_SUPERUSER_EMAIL='admin@example.test' +DJANGO_SUPERUSER_PASSWORD='test-only-admin-password' + +### Gunicorn and startup tuning +WEB_CONCURRENCY='2' +GUNICORN_THREADS='2' +GUNICORN_TIMEOUT='300' +GUNICORN_KEEPALIVE='5' +APP_START_WAIT_TIMEOUT_SECONDS='100' -### Logs settings -LOG_TYPE="regular_folder" # can be symbolic link, or regular_folder -LOG_PATH="" # mandatory if LOG_TYPE="symbolic_link", where is the log folder so we can create a symbolic link in the repository folder. +### Application log policy compatibility +# Set LOG_PATH when LOG_TYPE='symbolic_link'. +LOG_TYPE='regular_folder' +LOG_PATH='' diff --git a/conf/iskylims_apache_centos_redhat.conf b/conf/iskylims_apache_centos_redhat.conf deleted file mode 100644 index 0360afcc4..000000000 --- a/conf/iskylims_apache_centos_redhat.conf +++ /dev/null @@ -1,45 +0,0 @@ -## NOTE: WSGISocketPrefix,WSGIPythonHome, WSGIScriptAlias and WSGIPythonPath cannot occur within section - -# Path to serve your app at and wsgi script path - - - ServerAdmin bioinformatica@isciii.es - ServerName iskylims.isciiides.es - DocumentRoot /opt/iskylims - - - ## Load wsgi module with library associated in virtualenv - LoadModule wsgi_module "/opt/iskylims/virtualenv/lib/python3.9/site-packages/mod_wsgi/server/mod_wsgi-py39.cpython-39-x86_64-linux-gnu.so" - - WSGIDaemonProcess iskylims.isciiides.es python-home=/opt/iskylims/virtualenv python-path=/opt/iskylims - WSGIProcessGroup iskylims.isciiides.es - WSGIScriptAlias / /opt/iskylims/iskylims/wsgi.py - WSGIPassAuthorization On - WSGIApplicationGroup %{GLOBAL} - - # Directory piece. This ensures that apache can access wsgi.py script. - - - Satisfy Any - Allow from all - - - - Alias /static /opt/iskylims/static - - - Satisfy Any - Allow from all - - - Alias /documents /opt/iskylims/documents - - - Satisfy Any - Allow from all - - - ErrorLog logs/iskylims/iskylims.isciiides.es-apache.error.log - CustomLog logs/iskylims/iskylims.isciiides.es-apache.access.log combined - - diff --git a/conf/iskylims_apache_reverse_proxy.conf b/conf/iskylims_apache_reverse_proxy.conf deleted file mode 100644 index 1fa9ba985..000000000 --- a/conf/iskylims_apache_reverse_proxy.conf +++ /dev/null @@ -1,42 +0,0 @@ -# Apache reverse proxy config for iSkyLIMS production container - - - ServerName __ISKYLIMS_SERVER_NAME__ - - ProxyPreserveHost On - RequestHeader set X-Forwarded-Proto "__ISKYLIMS_FORWARDED_PROTO__" - RequestHeader set X-Forwarded-Port "__ISKYLIMS_FORWARDED_PORT__" - RequestHeader set X-Forwarded-Host "__ISKYLIMS_SERVER_NAME__" - - - # Allow larger POST bodies to reach Django instead of being rejected by - # Apache first. Adjust this value if your deployment needs larger uploads. - LimitRequestBody 52428800 - - - SecRequestBodyLimit 52428800 - SecRequestBodyNoFilesLimit 52428800 - - - ProxyPass /static ! - Alias /static __INSTALL_PATH__/static - - Require all granted - - - ProxyPass /documents ! - Alias /documents __INSTALL_PATH__/documents - - Require all granted - - - ProxyPass / http://app:__APP_PORT__/ - ProxyPassReverse / http://app:__APP_PORT__/ - ProxyTimeout __GUNICORN_TIMEOUT__ - TimeOut __GUNICORN_TIMEOUT__ - - CustomLog logs/__ISKYLIMS_LOG_NAME__-apache.access.log combined env=!forwarded - CustomLog logs/__ISKYLIMS_LOG_NAME__-apache.access.log proxy env=forwarded - ErrorLog logs/__ISKYLIMS_LOG_NAME__-apache.error.log - - diff --git a/conf/iskylims_apache_ubuntu.conf b/conf/iskylims_apache_ubuntu.conf deleted file mode 100644 index 6c46b24a9..000000000 --- a/conf/iskylims_apache_ubuntu.conf +++ /dev/null @@ -1,45 +0,0 @@ -## NOTE: WSGISocketPrefix,WSGIPythonHome, WSGIScriptAlias and WSGIPythonPath cannot occur within section - -# Path to serve your app at and wsgi script path - - - ServerAdmin bioinformatica@isciii.es - ServerName iSkyLIMS.isciiides.es - DocumentRoot /opt/iSkyLIMS - - - ## Load wsgi module with library associated in virtualenv - LoadModule wsgi_module "/opt/iSkyLIMS/virtualenv/lib/python3.9/site-packages/mod_wsgi/server/mod_wsgi-py39.cpython-39-x86_64-linux-gnu.so" - - WSGIDaemonProcess iSkyLIMS.isciiides.es python-home=/opt/iSkyLIMS/virtualenv python-path=/opt/iSkyLIMS - WSGIProcessGroup iSkyLIMS.isciiides.es - WSGIScriptAlias / /opt/iSkyLIMS/iSkyLIMS/wsgi.py - WSGIPassAuthorization On - WSGIApplicationGroup %{GLOBAL} - - # Directory piece. This ensures that apache can access wsgi.py script. - - - Satisfy Any - Allow from all - - - - Alias /static /opt/iSkyLIMS/static - - - Satisfy Any - Allow from all - - - Alias /documents /opt/iSkyLIMS/documents - - - Satisfy Any - Allow from all - - - ErrorLog logs/iSkyLIMS/iSkyLIMS.isciiides.es-apache.error.log - CustomLog logs/iSkyLIMS/iSkyLIMS.isciiides.es-apache.access.log combined - - diff --git a/conf/samba/samba_production_settings.txt b/conf/samba/samba_production_settings.txt new file mode 100644 index 000000000..179a870ee --- /dev/null +++ b/conf/samba/samba_production_settings.txt @@ -0,0 +1,2 @@ + +# Samba demo storage is intentionally unavailable in production. diff --git a/conf/samba/samba_test_settings.txt b/conf/samba/samba_test_settings.txt new file mode 100644 index 000000000..0bc163848 --- /dev/null +++ b/conf/samba/samba_test_settings.txt @@ -0,0 +1,4 @@ + +# Samba test/demo data add-on +SAMBA_USER='samba_user' +SAMBA_PASSWORD='sambapasswd' diff --git a/conf/template_install_settings.txt b/conf/template_install_settings.txt index bd99c0d70..f762fc324 100644 --- a/conf/template_install_settings.txt +++ b/conf/template_install_settings.txt @@ -2,7 +2,6 @@ INSTALL_PATH='/opt/iskylims' REQUIRED_MODULES='core drylab wetlab clinic django_utils' MIGRATION_MODULES='core drylab wetlab django_utils' -FAKEINITIAL_MODULES='django_utils iSkyLIMS_core iSkyLIMS_wetlab iSkyLIMS_drylab' ### Container runtime settings # Host directory for Apache bind-mounted configuration files. @@ -59,6 +58,12 @@ EMAIL_HOST_USER='bioinformatica@isciii.es' EMAIL_HOST_PASSWORD='' EMAIL_USE_TLS='False' +### Initial administrator (bootstrap install only) +CREATE_INITIAL_SUPERUSER='true' +DJANGO_SUPERUSER_USERNAME='admin' +DJANGO_SUPERUSER_EMAIL='' +DJANGO_SUPERUSER_PASSWORD='' + ### Settings required for accessing iSkyLIMS # example: 172.0.0.1 LOCAL_SERVER_IP='' diff --git a/conf/template_settings.txt b/conf/template_settings.py similarity index 59% rename from conf/template_settings.txt rename to conf/template_settings.py index 6367cb736..faba011c8 100644 --- a/conf/template_settings.txt +++ b/conf/template_settings.py @@ -1,40 +1,35 @@ -""" -Django settings for iSkyLIMS project. - -Generated by 'django-admin startproject' using Django 1.11.4. - -For more information on this file, see -https://docs.djangoproject.com/en/1.11/topics/settings/ +"""Django settings template for iSkyLIMS. -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.11/ref/settings/ +Keep application behavior and the exact Django application list in this file. +The BU-ISCIII deployment renderer replaces environment-specific database, +email, host, CSRF and secret values from the selected installation settings. """ import os -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# The renderer replaces this complete line and preserves the generated secret +# during upgrades. Never put a real production secret in this repository. +SECRET_KEY = "PLACEHOLDER" +DEBUG = djangodebug # noqa: F821 - replaced by the deployment renderer +ALLOWED_HOSTS = [ + host.strip() for host in "djangoallowedhosts".split(",") if host.strip() +] +CSRF_TRUSTED_ORIGINS = [ + origin.strip() for origin in "djangocsrftrustedorigins".split(",") if origin.strip() +] -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = SECRET - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "localserverip"] - -# Application definition - +# iSkyLIMS local applications. Add new applications here when their models, +# URLs, templates, signals or management commands must be registered by Django. +# Prefer "package.apps.AppConfigClass" when an application defines AppConfig. INSTALLED_APPS = [ "core", - # "clinic", + # "clinic", # Enable only when the clinic application is deployed. "wetlab", "drylab", "django_utils", + # Third-party applications required by iSkyLIMS. "mptt", "crispy_forms", "crispy_bootstrap5", @@ -52,12 +47,13 @@ "django_cleanup", ] +# Application names shown by the iSkyLIMS interface. When adding an application +# to INSTALLED_APPS, add it here only if it must appear in that interface. APPS_NAMES = [ ["wetlab", "Genomics unit: massive sequencing"], ["drylab", "Bioinformatics unit: analysis requests"], ] - MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -69,10 +65,12 @@ ] ROOT_URLCONF = "iskylims.urls" +WSGI_APPLICATION = "iskylims.wsgi.application" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", + # iSkyLIMS stores user-managed dry-lab service templates persistently. "DIRS": [BASE_DIR + "/documents/drylab/services_templates"], "APP_DIRS": True, "OPTIONS": { @@ -84,14 +82,9 @@ "django.template.context_processors.i18n", ], }, - }, + } ] -WSGI_APPLICATION = "iskylims.wsgi.application" - - -# Database -# https://docs.djangoproject.com/en/1.11/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", @@ -99,17 +92,14 @@ "PASSWORD": "djangopass", "PORT": "djangoport", "NAME": "djangodbname", - "HOST": "djangohost", + "HOST": os.getenv("DB_HOST", "djangohost"), + "CONN_MAX_AGE": dbconnmaxage, # noqa: F821 - deployment placeholder "TEST": { "NAME": "iSkyLIMS_test", }, - }, + } } - -# Password validation -# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", @@ -124,74 +114,49 @@ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] -SWAGGER_SETTINGS = {"SECURITY_DEFINITIONS": {"basic": {"type": "basic"}}} - -""" For using token in the authorization request - 'api_key': { - 'type': 'apiKey', - 'in': 'header', - 'name': 'Authorization' - } -""" -# 'PERSIST_AUTH': True -# Internationalization -# https://docs.djangoproject.com/en/1.11/topics/i18n/ +# Swagger currently uses HTTP Basic authentication. To use authorization tokens, +# extend SECURITY_DEFINITIONS with an apiKey entry and test schema access. +SWAGGER_SETTINGS = {"SECURITY_DEFINITIONS": {"basic": {"type": "basic"}}} LANGUAGE_CODE = "en-us" TIME_ZONE = "Europe/Madrid" - USE_I18N = True - USE_L10N = True - USE_TZ = False - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.11/howto/static-files/ - STATIC_URL = "/static/" STATIC_ROOT = os.path.join(BASE_DIR, "static/") - -# Media settings MEDIA_URL = "/documents/" MEDIA_ROOT = os.path.join(BASE_DIR, "documents/") -# Crispy forms settings CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" CRISPY_TEMPLATE_PACK = "bootstrap5" - -# Redirect to home URL after login (Default redirects to /accounts/profile/) LOGIN_REDIRECT_URL = "/" -#EMAIL_BACKEND = ( -# "django.core.mail.backends.console.EmailBackend" # During development only -#) - -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' - -# EMAIL settings +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "emailhostserver" EMAIL_PORT = "emailport" EMAIL_HOST_USER = "emailhostuser" EMAIL_HOST_PASSWORD = "emailhostpassword" -EMAIL_USE_TLS = emailhosttls +EMAIL_USE_TLS = emailhosttls # noqa: F821 - replaced by the deployment renderer ALLOWED_EMAIL_DOMAINS = ["isciii.es", "externos.isciii.es"] +# django-crontab writes into the persistent application log directory. Add new +# jobs as (schedule, callable, output redirection) tuples and verify them with +# `python manage.py crontab show` after deployment. LOG_CRONTAB_FILE = os.path.join(BASE_DIR, "logs", "crontab.log") LOG_CLEAN_FILE = os.path.join(BASE_DIR, "logs", "crontab_cleanup.log") - - -# Crontab settings CRONJOBS = [ ("*/15 * * * *", "wetlab.cron.looking_for_new_runs", ">>" + LOG_CRONTAB_FILE), ] - CRONTAB_COMMAND_SUFFIX = "2>&1" -DEFAULT_AUTO_FIELD = "django.db.models.AutoField" -DATA_UPLOAD_MAX_MEMORY_SIZE = 10000000 +# Maximum request body retained in memory before Django streams to disk. +DATA_UPLOAD_MAX_MEMORY_SIZE = 10_000_000 -# Needed when using a proxy for https forwading -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') \ No newline at end of file +# Trust this header only because Apache overwrites X-Forwarded-Proto before +# forwarding requests to Django. Never expose Gunicorn directly to untrusted clients. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/conf/urls.py b/conf/urls.py index 6e3135538..bdcf49cac 100644 --- a/conf/urls.py +++ b/conf/urls.py @@ -26,6 +26,9 @@ def get_schema(self, request=None, public=False): urlpatterns = [ + # Stable unauthenticated endpoint consumed by Compose and deployment smoke + # tests. Database readiness is verified separately during bootstrap. + path("health/", include("deployment_health.urls")), path("", include("core.urls")), path( "background", diff --git a/container_install.sh b/container_install.sh old mode 100644 new mode 100755 index bf79e7769..101414d7f --- a/container_install.sh +++ b/container_install.sh @@ -1,1052 +1,591 @@ -#!/usr/bin/bash - -ISKYLIMS_VERSION="3.1.1" - -usage() { -cat << EOF -This script installs and upgrades the iskylims app. - -Usage : $0 [--demo_data] [--git_revision] [--compose_file] [--install_conf] [--action] [--script] [--script_before] [--script_after] [--engine] [--test] - Optional input data: - --demo_data | Provide already downloaded demo data from Zenodo - --git_revision | Specify the Git revision to install (default: main, or 'current' to use copied local sources) - --compose_file | Compose file to use (overrides default) - --install_conf | Settings file consumed during container image build (mandatory for production) - --install_conf_map | Service-specific settings file: service,path (can be repeated) - --action | install (default), upgrade, or fix-permissions - --script | Run a Django migration script after migrations (can be repeated) - --script_before | Run a Django migration script before migrations (can be repeated) - --script_after | Run a Django migration script after migrations (can be repeated) - --skip_demo_data | Skip downloading/copying demo data to samba container - --skip_test_data | Skip loading test fixtures (test/test_data.json) - --engine | Container engine to use: docker (default) or podman - --test | Use development/test compose file and sample data - -Examples: - Deploy production container pointing to an external DB/Samba: - bash $0 --install_conf conf/my_prod_settings_iskylims.txt - - Deploy production with service-specific settings mapping: - bash $0 --install_conf_map app,conf/docker_production_settings.txt - - Upgrade an existing production deployment using the same database: - bash $0 --install_conf conf/my_prod_settings_iskylims.txt --action upgrade - - Repair production bind mount and volume permissions without rebuilding or bootstrapping: - bash $0 --install_conf conf/my_prod_settings_iskylims.txt --action fix-permissions - - Install demo container system with local services - bash $0 --test - - Install test stack from current local committed sources without checking out a branch in-container - bash $0 --test --git_revision current - - Provide already downloaded data from Zenodo (compressed) for test environment - bash $0 --demo_data /path/to/iskylims_demo_data.tar.gz - -EOF -} - -# translate long options to short -reset=true - -for arg in "$@" -do - if [ -n "$reset" ]; then - unset reset - set -- # this resets the "$@" array so we can rebuild it - fi - case "$arg" in - # OPTIONAL - --demo_data) set -- "$@" -d ;; - --git_revision) set -- "$@" -g ;; - --compose_file) set -- "$@" -c ;; - --install_conf) set -- "$@" -s ;; - --install_conf_map) set -- "$@" -j ;; - --action) set -- "$@" -a ;; - --script) set -- "$@" -m ;; - --script_before) set -- "$@" -b ;; - --script_after) set -- "$@" -f ;; - --skip_demo_data) set -- "$@" -n ;; - --skip_test_data) set -- "$@" -t ;; - --test) set -- "$@" -p ;; - --engine) set -- "$@" -e ;; - - # ADDITIONAL - --help) set -- "$@" -h ;; - --version) set -- "$@" -v ;; - # PASSING VALUE IN PARAMETER - *) set -- "$@" "$arg" ;; +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$script_dir/deployment/lib/container/common.sh" +# shellcheck disable=SC1091 +source "$script_dir/deployment/lib/container/django.sh" + +APP_VERSION="0.1.0" +APPLICATION_NAME="iSkyLIMS" + +# ============================================================================ +# GENERATED SERVICE/ADD-ON CUSTOMIZATION +# Regenerate these callbacks from the descriptor; keep application-neutral +# lifecycle mechanics below unchanged. +# ============================================================================ +install_services=(iskylims) +addon_build_services=() +permission_services=(iskylims iskylims-apache) +configured_services=(iskylims apache samba) + +default_service_install_conf() { + case "$1" in + iskylims) [ "$mode" = test ] && echo conf/docker_test_settings.txt || echo conf/docker_production_settings.txt ;; + apache) [ "$mode" = test ] && echo conf/apache/apache_test_settings.txt || echo conf/apache/apache_production_settings.txt ;; + samba) [ "$mode" = test ] && echo conf/samba/samba_test_settings.txt || echo conf/samba/samba_production_settings.txt ;; + *) return 1 ;; esac -done - -# SETTING DEFAULT VALUES -demo_data=false -git_revision="main" -compose_file="" -install_conf="" -install_conf_container="" -install_conf_map_entries=() -skip_demo_data="" -skip_test_data="" -mode="production" -action="install" -run_script=false -run_script_before=false -migration_script=() -migration_script_before=() -engine="docker" - -ENGINE_CMD=() -COMPOSE_CMD=() - -set_engine() { - if [ "$engine" = "docker" ]; then - if ! command -v docker >/dev/null 2>&1; then - echo "docker not found. Install docker or use --engine podman." - exit 1 - fi - ENGINE_CMD=("docker") - COMPOSE_CMD=("docker" "compose") - else - if ! command -v podman >/dev/null 2>&1; then - echo "podman not found. Install podman or use --engine docker." - exit 1 - fi - ENGINE_CMD=("podman") - if command -v podman-compose >/dev/null 2>&1; then - COMPOSE_CMD=("podman-compose") - elif podman compose version >/dev/null 2>&1; then - COMPOSE_CMD=("podman" "compose") - else - echo "podman compose not available. Install podman-compose or use --engine docker." - exit 1 - fi - fi -} - -engine_exec() { - "${ENGINE_CMD[@]}" "$@" -} - -compose_exec() { - "${COMPOSE_CMD[@]}" "$@" -} - -copy_with_podman_fallback() { - local src="$1" - local dst="$2" - - if cp "$src" "$dst" 2>/dev/null; then - return 0 - fi - - if [ "$engine" = "podman" ]; then - if podman unshare cp "$src" "$dst"; then - return 0 - fi - fi - - echo "Failed to copy '$src' to '$dst'" >&2 - return 1 -} - -chmod_with_podman_fallback() { - local mode="$1" - shift - - if chmod "$mode" "$@" 2>/dev/null; then - return 0 - fi - - if [ "$engine" = "podman" ]; then - if podman unshare chmod "$mode" "$@"; then - return 0 - fi - fi - - echo "Failed to chmod $mode: $*" >&2 - return 1 } - -chown_with_podman_fallback() { - local owner="$1" - shift - - if chown -R "$owner" "$@" 2>/dev/null; then - return 0 - fi - - if [ "$engine" = "podman" ]; then - if podman unshare chown -R "$owner" "$@"; then - return 0 - fi - fi - - echo "Failed to chown $owner: $*" >&2 - return 1 +service_build_context_dir() { + case "$1" in + iskylims) echo . ;; + *) return 1 ;; + esac } - -normalize_apache_server_name() { - local value="$1" - - value="${value#http://}" - value="${value#https://}" - value="${value%%/*}" - value="${value%%:*}" - - if [ -z "$value" ] || [ "$value" = "*" ]; then - value="localhost" - fi - - echo "$value" +service_environment_prefix() { + local prefix + array_contains "$1" "${install_services[@]}" || return 1 + prefix="${1^^}" + printf '%s\n' "${prefix//-/_}" } - -generate_django_secret_key() { - if command -v python3 >/dev/null 2>&1; then - python3 -c "import secrets; print(''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)') for _ in range(50)))" +service_environment_value() { + local prefix variable + prefix="$(service_environment_prefix "$1")" || return 1 + variable="${prefix}_$2" + if [ -n "${!variable:-}" ]; then + printf '%s\n' "${!variable}" + elif [ "$#" -ge 3 ]; then + printf '%s\n' "$3" else - LC_ALL=C tr -dc 'A-Za-z0-9!@#$%^&*(-_=+)' < /dev/urandom | head -c 50 - printf "\n" + die "$variable is required in the rendered service settings" fi } - -sed_replacement_escape() { - printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' +service_repo_path() { + service_environment_value "$1" REPO_PATH } - -render_django_settings_file() { - local settings_path="$1" - local secret_line="" - local tmp_file="" - local db_user db_pass db_name db_host db_port - local email_host email_port email_user email_pass email_tls - local local_server_ip dns_url - - if [ -f "$settings_path" ]; then - secret_line="$(grep -E "^SECRET_KEY[[:space:]]*=" "$settings_path" | tail -n 1)" - fi - if [ -z "$secret_line" ] || [[ "$secret_line" =~ SECRET_KEY[[:space:]]*=[[:space:]]*SECRET ]]; then - secret_line="SECRET_KEY = '$(generate_django_secret_key)'" - fi - - db_user="$(read_install_conf_value DB_USER "$host_install_conf_path")" - db_pass="$(read_install_conf_value DB_PASS "$host_install_conf_path")" - db_name="$(read_install_conf_value DB_NAME "$host_install_conf_path")" - db_host="$(read_install_conf_value DB_SERVER_IP "$host_install_conf_path")" - db_port="$(read_install_conf_value DB_PORT "$host_install_conf_path")" - email_host="$(read_install_conf_value EMAIL_HOST_SERVER "$host_install_conf_path")" - email_port="$(read_install_conf_value EMAIL_PORT "$host_install_conf_path")" - email_user="$(read_install_conf_value EMAIL_HOST_USER "$host_install_conf_path")" - email_pass="$(read_install_conf_value EMAIL_HOST_PASSWORD "$host_install_conf_path")" - email_tls="$(read_install_conf_value EMAIL_USE_TLS "$host_install_conf_path")" - local_server_ip="$(read_install_conf_value LOCAL_SERVER_IP "$host_install_conf_path")" - dns_url="$(read_install_conf_value DNS_URL "$host_install_conf_path")" - - tmp_file="$(mktemp)" - cp "$repo_root/conf/template_settings.txt" "$tmp_file" - sed -i \ - -e "s|^SECRET_KEY.*|$(sed_replacement_escape "$secret_line")|" \ - -e "s|djangouser|$(sed_replacement_escape "$db_user")|g" \ - -e "s|djangopass|$(sed_replacement_escape "$db_pass")|g" \ - -e "s|djangohost|$(sed_replacement_escape "$db_host")|g" \ - -e "s|djangoport|$(sed_replacement_escape "$db_port")|g" \ - -e "s|djangodbname|$(sed_replacement_escape "$db_name")|g" \ - -e "s|emailhostserver|$(sed_replacement_escape "$email_host")|g" \ - -e "s|emailport|$(sed_replacement_escape "$email_port")|g" \ - -e "s|emailhostuser|$(sed_replacement_escape "$email_user")|g" \ - -e "s|emailhostpassword|$(sed_replacement_escape "$email_pass")|g" \ - -e "s|emailhosttls|$(sed_replacement_escape "$email_tls")|g" \ - -e "s|localserverip|$(sed_replacement_escape "$local_server_ip")|g" \ - -e "s|localhost|$(sed_replacement_escape "$dns_url")|g" \ - "$tmp_file" - - if copy_with_podman_fallback "$tmp_file" "$settings_path"; then - if ! chmod_with_podman_fallback 0664 "$settings_path"; then - rm -f "$tmp_file" - return 1 - fi - rm -f "$tmp_file" - return 0 - fi - - rm -f "$tmp_file" - return 1 +service_install_path() { + service_environment_value "$1" INSTALL_PATH } - -normalize_settings_bind_path() { - local value="$1" - - if [ -z "$value" ]; then - echo "$install_path/iskylims/settings.py" - return 0 - fi - - if [ -d "$value" ] || [[ "$value" = */ ]] || [[ "$value" != *.py ]]; then - echo "${value%/}/settings.py" - return 0 - fi - - echo "$value" +service_readiness_path() { + case "$1" in + iskylims) echo "$(service_install_path "$1")/manage.py" ;; + *) return 1 ;; + esac } - -render_apache_config() { - local src="$1" - local dst="$2" - local tmp_file="" - - tmp_file="$(mktemp)" - sed \ - -e "s|__ISKYLIMS_SERVER_NAME__|$apache_server_name|g" \ - -e "s|__ISKYLIMS_LOG_NAME__|$apache_log_name|g" \ - -e "s|__INSTALL_PATH__|$install_path|g" \ - -e "s|__APP_PORT__|$app_port|g" \ - -e "s|__ISKYLIMS_FORWARDED_PROTO__|$(sed_replacement_escape "$apache_forwarded_proto")|g" \ - -e "s|__ISKYLIMS_FORWARDED_PORT__|$(sed_replacement_escape "$apache_forwarded_port")|g" \ - -e "s|__GUNICORN_TIMEOUT__|$gunicorn_timeout|g" \ - -e "s|__SERVER_STATUS_SERVER_NAME__|$(sed_replacement_escape "$apache_status_server_name")|g" \ - -e "s|__SERVER_STATUS_ALIASES__|$(sed_replacement_escape "$apache_status_aliases")|g" \ - -e "s|__SERVER_STATUS_ALLOW_FROM__|$(sed_replacement_escape "$apache_status_allow_from")|g" \ - "$src" > "$tmp_file" - - if copy_with_podman_fallback "$tmp_file" "$dst"; then - if ! chmod_with_podman_fallback 0664 "$dst"; then - rm -f "$tmp_file" - return 1 - fi - rm -f "$tmp_file" - return 0 - fi - - rm -f "$tmp_file" - return 1 +service_image_name() { + case "$1" in + iskylims) echo iskylims:local ;; + *) return 1 ;; + esac } - -prepare_django_settings_bind_mount() { - local settings_path="$1" - local configured_db_host="" - - if [ "$mode" != "production" ]; then - return 0 - fi - - if [ -d "$settings_path" ]; then - echo "DJANGO_SETTINGS_PATH must resolve to a file path, but '$settings_path' is a directory." >&2 - echo "Use a full path like '$settings_path/settings.py' or remove the directory and rerun." >&2 - return 1 - fi - - mkdir -p "$(dirname "$settings_path")" - configured_db_host="$(read_install_conf_value DB_SERVER_IP "$host_install_conf_path")" - if [ ! -f "$settings_path" ] \ - || grep -Eq "SECRET_KEY[[:space:]]*=[[:space:]]*SECRET|emailhosttls|djangouser|djangopass|djangohost|djangodbname" "$settings_path" \ - || ! grep -Fq -- "\"HOST\": \"$configured_db_host\"," "$settings_path"; then - render_django_settings_file "$settings_path" - fi - chmod_with_podman_fallback 0664 "$settings_path" +service_profile() { + case "$1" in + iskylims) echo django ;; + *) return 1 ;; + esac } - -# PARSE VARIABLE ARGUMENTS WITH getopts -options=":d:g:c:s:j:a:m:b:f:e:vhntp" -while getopts $options opt; do - case $opt in - d) - demo_data=$OPTARG - ;; - g) - git_revision=$OPTARG - ;; - c) - compose_file=$OPTARG - ;; - s) - install_conf=$OPTARG - ;; - j) - install_conf_map_entries+=("$OPTARG") - ;; - a) - action=$OPTARG - if [[ "$action" != "install" && "$action" != "upgrade" && "$action" != "fix-permissions" ]]; then - echo "Invalid action '$action'. Use install, upgrade, or fix-permissions." - exit 1 - fi - ;; - m) - run_script=true - migration_script+=("$OPTARG") - ;; - b) - run_script_before=true - migration_script_before+=("$OPTARG") - ;; - e) - engine=$OPTARG - if [[ "$engine" != "docker" && "$engine" != "podman" ]]; then - echo "Invalid engine '$engine'. Use docker or podman." - exit 1 - fi - ;; - f) - run_script=true - migration_script+=("$OPTARG") - ;; - n) - skip_demo_data=true - ;; - t) - skip_test_data=true - ;; - p) - mode="test" - ;; - h) - usage - exit 1 - ;; - v) - echo $ISKYLIMS_VERSION - exit 1 - ;; - \?) - echo "Invalid Option: -$OPTARG" 1>&2 - usage - exit 1 - ;; - : ) - echo "Option -$OPTARG requires an argument." >&2 - exit 1 - ;; - * ) - echo "Unimplemented option: -$OPTARG" >&2; - exit 1 - ;; +service_dockerfile() { + case "$1" in + iskylims) echo Dockerfile ;; + *) return 1 ;; esac -done -shift $((OPTIND-1)) - -if [ "$mode" = "test" ]; then - if [ -z "$compose_file" ]; then - compose_file="docker-compose.test.yml" - fi -else - if [ -z "$compose_file" ]; then - compose_file="docker-compose.prod.yml" - fi -fi - -app_service="${APP_SERVICE:-app}" -selected_install_conf="$install_conf" -for map_entry in "${install_conf_map_entries[@]}"; do - svc_name="${map_entry%%,*}" - conf_name="${map_entry#*,}" - if [ -z "$svc_name" ] || [ -z "$conf_name" ] || [ "$svc_name" = "$map_entry" ]; then - echo "Invalid --install_conf_map value '$map_entry'. Expected format: service,path" - exit 1 - fi - if [ "$svc_name" != "app" ]; then - echo "Unknown service '$svc_name' in --install_conf_map. Valid service: app" - exit 1 - fi - selected_install_conf="$conf_name" -done - -if [ "$mode" = "test" ] && [ -z "$selected_install_conf" ]; then - selected_install_conf="conf/docker_test_settings.txt" -fi -install_conf="$selected_install_conf" - -if [ "$mode" = "production" ] && [ -z "$install_conf" ]; then - echo "Production deployments require --install_conf or --install_conf_map app,." - exit 1 -fi - -if [ -z "$skip_demo_data" ]; then - if [ "$mode" = "test" ]; then - skip_demo_data=false - else - skip_demo_data=true - fi -fi - -if [ -z "$skip_test_data" ]; then - if [ "$mode" = "test" ]; then - skip_test_data=false - else - skip_test_data=true - fi -fi - -if [ "$action" = "upgrade" ]; then - skip_demo_data=true - skip_test_data=true -fi - -if [ ! -f "$compose_file" ]; then - echo "Compose file '$compose_file' not found" - exit 1 -fi - -if [ ! -f "$install_conf" ]; then - echo "Install configuration '$install_conf' not found" - exit 1 -fi - -repo_root="$(pwd)" -build_context_dir="$repo_root" -if [ ! -d "$build_context_dir" ]; then - echo "Build context directory '$build_context_dir' not found" - exit 1 -fi - -temp_install_conf="" -if [[ "$install_conf" = /* ]] && [[ "$install_conf" != "$build_context_dir/"* ]]; then - temp_install_conf="$build_context_dir/.tmp_docker_install_conf_app_$$.txt" - echo "Copying $install_conf into temporary file $temp_install_conf for Docker build/runtime." - cp "$install_conf" "$temp_install_conf" - install_conf="$temp_install_conf" - cleanup_temp_conf() { - if [ -n "$temp_install_conf" ] && [ -f "$temp_install_conf" ]; then - rm -f "$temp_install_conf" - fi - } - trap cleanup_temp_conf EXIT -fi - -if [[ "$install_conf" = "$build_context_dir/"* ]]; then - install_conf_container="${install_conf#$build_context_dir/}" -else - install_conf_container="$install_conf" -fi - -host_install_conf_path="$install_conf" -if [[ "$host_install_conf_path" != /* ]]; then - host_install_conf_path="$repo_root/$host_install_conf_path" -fi - -read_install_conf_value() { - local key="$1" - local file="$2" - - bash -c ' - set -a - . "$1" - key="$2" - printf "%s" "${!key-}" - ' _ "$file" "$key" } - -config_value_or_default() { - local key="$1" - local default_value="$2" - local config_value="" - local env_value="${!key:-}" - - if [ -n "$env_value" ]; then - echo "$env_value" - return 0 - fi - - config_value="$(read_install_conf_value "$key" "$host_install_conf_path")" - if [ -n "$config_value" ]; then - echo "$config_value" - else - echo "$default_value" - fi +service_container_install_conf() { + case "$1" in + iskylims) echo conf/.runtime_install_settings.txt ;; + *) return 1 ;; + esac } - -write_compose_env_file() { - if [ "$mode" != "production" ]; then - return 0 - fi - - cat > "$compose_env_file" << EOF -# Generated by container_install.sh from $install_conf_container. -# Used by Docker Compose/Podman Compose for docker-compose.prod.yml interpolation. -INSTALL_TYPE=dep -GIT_REVISION=$git_revision -INSTALL_CONF=$install_conf_container -INSTALL_PATH=$install_path -APACHE_CONF_PATH=$apache_conf_path -DJANGO_SETTINGS_PATH=$django_settings_path -APP_UID=$app_uid -APP_GID=$app_gid -APP_SHELL=$app_shell -APP_PORT=$app_port -DJANGO_DEBUG=$django_debug -DB_CONN_MAX_AGE=$db_conn_max_age -WEB_CONCURRENCY=$web_concurrency -GUNICORN_THREADS=$gunicorn_threads -GUNICORN_TIMEOUT=$gunicorn_timeout -GUNICORN_KEEPALIVE=$gunicorn_keepalive -SERVER_STATUS_SERVER_NAME=$apache_status_server_name -SERVER_STATUS_ALIASES=$apache_status_aliases -SERVER_STATUS_ALLOW_FROM=$apache_status_allow_from -APACHE_FORWARDED_PROTO=$apache_forwarded_proto -APACHE_FORWARDED_PORT=$apache_forwarded_port -EOF - - echo "Wrote Compose environment file: $compose_env_file" +service_uid() { + service_environment_value "$1" APP_UID } - -compose_with_env_exec() { - if [ "$mode" = "production" ] && [ -f "$compose_env_file" ]; then - compose_exec --env-file "$compose_env_file" "$@" - else - compose_exec "$@" - fi +service_gid() { + service_environment_value "$1" APP_GID } -set_engine - -app_repo_path="${APP_REPO_PATH:-/srv/iskylims}" -config_install_path="$(read_install_conf_value "INSTALL_PATH" "$host_install_conf_path")" -install_path="${config_install_path:-/opt/iskylims}" -config_apache_conf_path="$(read_install_conf_value "APACHE_CONF_PATH" "$host_install_conf_path")" -apache_conf_path="${APACHE_CONF_PATH:-${config_apache_conf_path:-}}" -if [ -z "$apache_conf_path" ]; then - apache_conf_path="$install_path/conf" -fi -config_django_settings_path="$(read_install_conf_value "DJANGO_SETTINGS_PATH" "$host_install_conf_path")" -django_settings_path="$(normalize_settings_bind_path "${DJANGO_SETTINGS_PATH:-${config_django_settings_path:-}}")" -app_uid="$(config_value_or_default APP_UID 1212)" -app_gid="$(config_value_or_default APP_GID 1212)" -app_shell="$(config_value_or_default APP_SHELL /sbin/nologin)" -app_port="$(config_value_or_default APP_PORT 8001)" -django_debug="$(config_value_or_default DJANGO_DEBUG false)" -db_conn_max_age="$(config_value_or_default DB_CONN_MAX_AGE 60)" -web_concurrency="$(config_value_or_default WEB_CONCURRENCY 2)" -gunicorn_threads="$(config_value_or_default GUNICORN_THREADS 2)" -gunicorn_timeout="$(config_value_or_default GUNICORN_TIMEOUT 300)" -gunicorn_keepalive="$(config_value_or_default GUNICORN_KEEPALIVE 5)" -config_dns_url="$(read_install_conf_value "DNS_URL" "$host_install_conf_path")" -apache_server_name="$(normalize_apache_server_name "${APACHE_SERVER_NAME:-${config_dns_url:-localhost}}")" -apache_log_name="$(printf '%s' "$apache_server_name" | tr -c 'A-Za-z0-9._-' '_' | sed 's/_$//')" -apache_status_server_name="$(normalize_apache_server_name "$(config_value_or_default SERVER_STATUS_SERVER_NAME "$apache_server_name")")" -apache_status_aliases="$(config_value_or_default SERVER_STATUS_ALIASES "127.0.0.1 localhost")" -apache_status_allow_from="$(config_value_or_default SERVER_STATUS_ALLOW_FROM "127.0.0.1 localhost")" -apache_forwarded_proto="$(config_value_or_default APACHE_FORWARDED_PROTO https)" -apache_forwarded_port="$(config_value_or_default APACHE_FORWARDED_PORT 443)" -compose_env_file="$repo_root/.env.prod.file" -app_container="" -local_head_hash="" -local_head_short="" -app_image_name="${APP_IMAGE_NAME:-iskylims_app}" -image_id_before_build="" -image_id_after_build="" - -# Check if a service exists in the compose file -# -# Parameters: -# $1 - Service name to check -# -# Returns: -# 0 if the service exists, 1 otherwise -service_exists() { - compose_with_env_exec -f "$compose_file" ps --services 2>/dev/null | grep -Fxq "$1" +prepare_compose_environment() { + local -a settings_sources=( + "ISKYLIMS|${install_conf_host_by_service[iskylims]}" + "|${install_conf_host_by_service[apache]}" + "|${install_conf_host_by_service[samba]}" + ) + local -a deployment_values=( + "GIT_REVISION|$git_revision" + "ISKYLIMS_IMAGE|iskylims:local" + ) + compose_env_file="$script_dir/.env.${mode}.file" + write_compose_environment_file "$compose_env_file" settings_sources deployment_values } -# Return the name of the container for a given service name. -# The container name is different based on whether we are in test mode or not. -# -# Parameters: -# $1 - Service name to return the container name for -# -# Returns: -# The name of the container for the given service name -service_container_name() { - local service_name="$1" - if [ "$mode" = "test" ]; then - case "$service_name" in - db) echo "db" ;; - samba) echo "samba" ;; - app) echo "iskylims_app" ;; - *) echo "" ;; - esac - else - case "$service_name" in - app) echo "iskylims_app" ;; - samba) echo "samba" ;; - *) echo "" ;; - esac - fi +# Every project uses the generated interpolation file in both modes because it +# combines service-specific settings sources with collision-safe prefixes. +deployment_compose() { + compose_exec --env-file "$compose_env_file" "$@" } - -# Resolve the container ID for the target app service. -# -# Returns: -# Sets global variable `app_container` to a valid container name/ID. -# -# Errors: -# Exits if unable to resolve a container for `app_service`. -resolve_app_container() { - local container_name - container_name="$(service_container_name "$app_service")" - - if [ -n "$container_name" ] && engine_exec inspect -f '{{.Id}}' "$container_name" >/dev/null 2>&1; then - app_container="$container_name" - else - app_container="$(engine_exec ps -a --filter "label=com.docker.compose.service=${app_service}" --format '{{.ID}}' | head -n 1)" - fi - - if [ -z "$app_container" ]; then - echo "Error: unable to resolve container ID for service '$app_service'." >&2 - exit 1 - fi +current_service_container() { + resolve_service_container "$1" } -try_resolve_app_container() { - local container_name - container_name="$(service_container_name "$app_service")" - - if [ -n "$container_name" ] && engine_exec inspect -f '{{.Id}}' "$container_name" >/dev/null 2>&1; then - app_container="$container_name" - return 0 - fi - - app_container="$(engine_exec ps -a --filter "label=com.docker.compose.service=${app_service}" --format '{{.ID}}' | head -n 1)" - [ -n "$app_container" ] +print_service_summary() { + echo + echo "Running services and published ports:" + deployment_compose -f "$compose_file" ps } -# Ensure target app service container exists and is running. -# -# Errors: -# Exits if container does not exist or is not running. -ensure_app_running() { - resolve_app_container - if ! engine_exec inspect -f '{{.State.Running}}' "$app_container" >/dev/null 2>&1; then - echo "Error: service '$app_service' container does not exist." - exit 1 - fi - if [ "$(engine_exec inspect -f '{{.State.Running}}' "$app_container")" != "true" ]; then - echo "Error: service '$app_service' container is not running. Showing logs:" - engine_exec logs --tail 200 "$app_container" - exit 1 - fi -} +# Each Django service renders its own protected host settings bind. React +# services and add-ons have no Django settings source. +prepare_application_host_sources() { + local settings_output + if [ "$mode" = production ]; then + settings_output="$(service_environment_value iskylims DJANGO_SETTINGS_PATH)" + [ -n "$settings_output" ] || { echo "DJANGO_SETTINGS_PATH is required for iskylims" >&2; return 1; } + mkdir -p "$(dirname "$settings_output")" + prepare_django_settings_bind_mount ./conf/template_settings.py "$settings_output" "${install_conf_host_by_service[iskylims]}" + fi + # conf/apache contains the application-owned Apache sources. Render every + # deployment value only after the protected settings environment is loaded, + # then expose the completed files as Compose bind sources. + local apache_source_dir="$script_dir/conf/apache" + local apache_output_dir="$script_dir/deployment/apache" + local apache_conf_name apache_config_service apache_log_path + apache_config_service=iskylims + [ -d "$apache_source_dir" ] || { + echo "Apache source configuration directory not found: $apache_source_dir" >&2 + return 1 + } + mkdir -p "$apache_output_dir" + + export APACHE_SERVER_NAME="${APACHE_SERVER_NAME:?APACHE_SERVER_NAME is required}" + export APACHE_UPSTREAM_SERVICE="${APACHE_UPSTREAM_SERVICE:-$apache_config_service}" + export APACHE_UPSTREAM_PORT="${APACHE_UPSTREAM_PORT:-$(service_environment_value "$apache_config_service" APP_PORT)}" + # For the default route, INSTALL_PATH means the service selected by + # ADDONS.apache.CONFIG_SERVICE. Multi-service routes use their explicit + # API_INSTALL_PATH, WEB_INSTALL_PATH, etc. values instead. + export INSTALL_PATH="$(service_install_path "$apache_config_service")" + export APACHE_PROXY_TIMEOUT="${APACHE_PROXY_TIMEOUT:-$(service_environment_value "$apache_config_service" GUNICORN_TIMEOUT 120)}" + export APACHE_LOG_STEM="${APACHE_LOG_STEM:-$(normalize_apache_server_name "$APACHE_SERVER_NAME")}" + + for apache_conf_name in 00-logs.conf 01-reverse-proxy.conf 02-server-status.conf; do + [ -f "$apache_source_dir/$apache_conf_name" ] || { + echo "Apache source configuration not found: $apache_source_dir/$apache_conf_name" >&2 + return 1 + } + render_environment_config_template \ + "$apache_source_dir/$apache_conf_name" \ + "$apache_output_dir/$apache_conf_name" 0644 || return 1 + done -print_local_source_diagnostics() { - echo "Local source diagnostics:" - if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - local_head_hash="$(git -C "$repo_root" rev-parse HEAD)" - local_head_short="$(git -C "$repo_root" rev-parse --short HEAD)" - echo " local HEAD: $(git -C "$repo_root" log -1 --oneline)" - echo " local HEAD hash: $local_head_hash" - else - echo " local git metadata unavailable" + # Production bind-mounts Apache logs from the host; tests use a named volume. + if [ "$mode" = production ]; then + apache_log_path="${APACHE_LOG_PATH:?APACHE_LOG_PATH is required}" + mkdir -p "$apache_log_path" fi } -print_existing_artifact_diagnostics() { - echo "Image diagnostics before build:" - if engine_exec image inspect "$app_image_name" >/dev/null 2>&1; then - image_id_before_build="$(engine_exec image inspect -f '{{.Id}}' "$app_image_name" 2>/dev/null || true)" - echo " image before build: $image_id_before_build" - else - image_id_before_build="" - echo " image before build: none" - fi +# Keep one independently reviewable host permission specification per +# application and per selected add-on. Empty add-on specs are intentional until +# that add-on declares writable bind sources. +prepare_host_bind_source_permissions() { + [ "$mode" = production ] || return 0 + local log_path settings_path uid gid + log_path="$(service_environment_value iskylims HOST_LOG_PATH)" + settings_path="$(service_environment_value iskylims DJANGO_SETTINGS_PATH)" + [ -n "$log_path" ] || { echo "HOST_LOG_PATH is required for iskylims" >&2; return 1; } + [ -n "$settings_path" ] || { echo "DJANGO_SETTINGS_PATH is required for iskylims" >&2; return 1; } + uid="$(service_uid iskylims)"; gid="$(service_gid iskylims)" + local -a iskylims_host_bind_permission_spec=( + "$log_path|$uid:$gid|0775" + "$(dirname "$settings_path")|-|0755" + "$settings_path|$uid:$gid|0664" + ) + apply_host_permission_spec "${iskylims_host_bind_permission_spec[@]}" + # Generated proxy configuration is read-only in Apache. Its host files need + # traversal/read permissions, while the production log bind must be writable. + apache_log_path="${APACHE_LOG_PATH:?APACHE_LOG_PATH is required}" + local -a apache_host_bind_permission_spec=( + "$script_dir/deployment/apache|-|0755" + "$script_dir/deployment/apache/00-logs.conf|-|0644" + "$script_dir/deployment/apache/01-reverse-proxy.conf|-|0644" + "$script_dir/deployment/apache/02-server-status.conf|-|0644" + # registry.access.redhat.com/ubi9/httpd-24 runs as UID 1001 with GID 0. + # The shared helper applies these IDs directly for Docker and through + # podman unshare when the bind source belongs to a rootless userns. + "$apache_log_path|1001:0|0775" + ) + apply_host_permission_spec "${apache_host_bind_permission_spec[@]}" } -print_image_after_build() { - echo "Image diagnostics after build:" - if engine_exec image inspect "$app_image_name" >/dev/null 2>&1; then - image_id_after_build="$(engine_exec image inspect -f '{{.Id}}' "$app_image_name" 2>/dev/null || true)" - echo " image after build: $image_id_after_build" - if [ -n "$image_id_before_build" ] && [ "$image_id_before_build" = "$image_id_after_build" ]; then - echo " image id check: unchanged" - elif [ -n "$image_id_before_build" ] && [ "$image_id_before_build" != "$image_id_after_build" ]; then - echo " image id check: changed" - else - echo " image id check: created" - fi - else - echo " image after build: not found" - fi +# Keep a separate running-mount specification in every service/add-on case. +prepare_running_container_mount_permissions() { + local service_name="$1" container_id="$2" + local install_path uid gid + case "$service_name" in + iskylims) + install_path="$(service_install_path "$service_name")" + uid="$(service_uid "$service_name")"; gid="$(service_gid "$service_name")" + local -a iskylims_running_mount_permission_spec=( + "$install_path/logs|$uid:$gid|u+rwX,g+rwX" + "$install_path/documents|$uid:$gid|u+rwX,g+rwX" + "$install_path/static|$uid:$gid|u+rwX,g+rwX,o+rX" + ) + apply_container_directory_permission_spec "$container_id" "${iskylims_running_mount_permission_spec[@]}" + prepare_django_container_settings_permissions "$container_id" "$install_path/iskylims/settings.py" "$uid" "$gid" + ;; + iskylims-apache) + # Apache currently needs no ownership repair inside its running + # container. Keep an explicit add-on policy ready for future mounts. + local -a apache_running_mount_permission_spec=() + apply_container_directory_permission_spec "$container_id" "${apache_running_mount_permission_spec[@]}" + ;; + *) return 0 ;; + esac } -print_container_source_diagnostics() { - local label="$1" - local container_repo_head_hash="" - local container_repo_head_short="" - echo "$label" - container_repo_head_hash="$(engine_exec exec "$app_container" sh -lc " - if [ -d '$app_repo_path/.git' ]; then - cd '$app_repo_path' && git rev-parse HEAD - fi - " 2>/dev/null | tail -n 1)" - container_repo_head_short="$(engine_exec exec "$app_container" sh -lc " - if [ -d '$app_repo_path/.git' ]; then - cd '$app_repo_path' && git rev-parse --short HEAD - fi - " 2>/dev/null | tail -n 1)" - if [ -n "$container_repo_head_hash" ]; then - echo " container /srv HEAD hash: $container_repo_head_hash" - fi - if [ -n "$local_head_hash" ] && [ -n "$container_repo_head_hash" ]; then - if [ "$local_head_hash" = "$container_repo_head_hash" ]; then - echo " HEAD check: OK local=$local_head_short container=$container_repo_head_short" - else - echo " HEAD check: MISMATCH local=$local_head_short container=$container_repo_head_short" - fi - fi - engine_exec exec "$app_container" sh -lc " - echo ' /srv/iskylims HEAD:' - if [ -d '$app_repo_path/.git' ]; then - cd '$app_repo_path' && git log -1 --oneline - else - echo 'not a git checkout' - fi - " || true +bootstrap_service() { + local service_name="$1" container_id="$2" deployment_action="$3" + local repo_path runtime_conf uid gid status + local -a args + case "$service_name" in + iskylims) + repo_path="$(service_repo_path "$service_name")" + # Fixed temporary in-container path; this is not operator configuration. + runtime_conf=conf/.runtime_install_settings.txt + [[ "$runtime_conf" == /* ]] || runtime_conf="$repo_path/$runtime_conf" + uid="$(service_uid "$service_name")"; gid="$(service_gid "$service_name")" + stage_container_runtime_config "$container_id" "${install_conf_host_by_service[$service_name]}" "$runtime_conf" "$uid" "$gid" + args=(--bootstrap "$deployment_action" --git_revision "$git_revision" --conf "$runtime_conf" --skip_apache_restart) + [ "$load_tables" = false ] || args+=(--tables) + [ "$skip_tables" = false ] || args+=(--skip_tables) + for hook in "${migration_script_before[@]}"; do args+=(--script_before "$hook"); done + for hook in "${migration_script_after[@]}"; do args+=(--script_after "$hook"); done + status=0; engine_exec exec "$container_id" bash "$repo_path/install.sh" "${args[@]}" || status=$? + [ "$mode" = test ] || remove_container_runtime_config "$container_id" "$runtime_conf" || true + return "$status" + ;; + *) return 0 ;; + esac } -prepare_app_mount_permissions() { - if [ "$mode" != "production" ]; then - return 0 - fi - - echo "Preparing writable app mount permissions..." - engine_exec exec --user 0 "$app_container" sh -lc " - set -e - mkdir -p '$install_path/logs' '$install_path/static' '$install_path/documents' '$install_path/cron' '$install_path/tmp' - chown -R '$app_uid:$app_gid' '$install_path/logs' '$install_path/static' '$install_path/documents' '$install_path/cron' '$install_path/tmp' - chmod -R u+rwX,g+rwX '$install_path/logs' '$install_path/static' '$install_path/documents' - chmod 700 '$install_path/cron' '$install_path/tmp' - if [ -f '$install_path/iskylims/settings.py' ]; then - chown '$app_uid:$app_gid' '$install_path/iskylims/settings.py' - chmod 0664 '$install_path/iskylims/settings.py' - fi - chmod -R o+rX '$install_path/static' - " +build_production_service() { + local service_name="$1" context="$2" dockerfile="$3" + case "$service_name" in + iskylims) + engine_build --no-cache --file "$context/$dockerfile" \ + --secret "id=install_conf,src=${install_conf_host_by_service[$service_name]}" \ + --build-arg GIT_REVISION="$git_revision" \ + --build-arg INSTALL_CONF="$(service_container_install_conf "$service_name")" \ + --build-arg USE_INSTALL_CONF_SECRET=true \ + --build-arg RENDER_DJANGO_SETTINGS=false \ + --build-arg APP_REPO_PATH="$(service_repo_path "$service_name")" \ + --build-arg APP_INSTALL_PATH="$(service_install_path "$service_name")" \ + --build-arg APP_PORT="$(service_environment_value "$service_name" APP_PORT)" \ + --build-arg APP_UID="$(service_uid "$service_name")" \ + --build-arg APP_GID="$(service_gid "$service_name")" \ + --tag "$(service_image_name "$service_name")" "$context" + ;; + *) die "Unsupported production build service: $service_name" ;; + esac } -prepare_host_bind_mount_permissions() { - if [ "$mode" != "production" ]; then - return 0 - fi - - local apache_conf_file - local django_settings_dir - - echo "Preparing host bind mount permissions..." - chmod_with_podman_fallback 0755 "$apache_conf_path" - - chown_with_podman_fallback "$app_uid:$app_gid" "/var/log/local/relecov-iskylims/apps" - chmod_with_podman_fallback 0775 "/var/log/local/relecov-iskylims/apps" - - # UBI httpd runs as uid 1001 and group 0. This keeps the Apache log bind - # writable without relying on Podman's :U ownership mutation. - chown_with_podman_fallback "1001:0" "/var/log/local/relecov-iskylims/apache" - chmod_with_podman_fallback 0775 "/var/log/local/relecov-iskylims/apache" +# Applications with disposable fixtures or demo files customize this callback +# in their generated wrapper and set application_supports_test_data=true. Keep +# application-specific fixture names, users/groups, downloads, and data-service +# layout here so the complete test installation remains readable in one file. +application_supports_test_data=true +load_test_deployment_data() { + local relecov_iskylims_container relecov_iskylims_install_path samba_container archive downloaded_archive + local admin_groups_code + + relecov_iskylims_container="$(current_service_container relecov-iskylims)" \ + || die "Unable to resolve the relecov-iskylims container for test-data loading" + relecov_iskylims_install_path="$(service_install_path relecov-iskylims)" + + if [ "$skip_test_data" = false ]; then + echo "Loading iSkyLIMS test fixtures" + engine_exec exec -w "$relecov_iskylims_install_path" "$relecov_iskylims_container" \ + "$relecov_iskylims_install_path/virtualenv/bin/python" manage.py \ + loaddata test/test_data.json + admin_groups_code=$(cat <<'PY' +from django.contrib.auth.models import Group, User - if [ -f "$django_settings_path" ]; then - django_settings_dir="$(dirname "$django_settings_path")" - chmod_with_podman_fallback 0755 "$django_settings_dir" - chown_with_podman_fallback "$app_uid:$app_gid" "$django_settings_path" - chmod_with_podman_fallback 0664 "$django_settings_path" +admin = User.objects.get(username="admin") +admin.groups.add( + Group.objects.get(name="WetlabManager"), + Group.objects.get(name="ServiceManager"), +) +print("admin groups:", list(admin.groups.values_list("name", flat=True))) +PY +) + engine_exec exec -w "$relecov_iskylims_install_path" "$relecov_iskylims_container" \ + "$relecov_iskylims_install_path/virtualenv/bin/python" manage.py \ + shell -c "$admin_groups_code" + else + echo "Skipping iSkyLIMS test fixtures as requested" fi - for apache_conf_file in \ - "$apache_conf_path/iskylims_apache_reverse_proxy.conf" \ - "$apache_conf_path/iskylims_apache_logs.conf" \ - "$apache_conf_path/iskylims_apache_server-status.conf"; do - if [ -f "$apache_conf_file" ]; then - chmod_with_podman_fallback 0664 "$apache_conf_file" - fi - done -} - -# Remove stale test containers left over from previous runs. -# -# This function will only be executed in "test" mode when the engine is "podman". -# It only removes known test container names and never removes volumes. -cleanup_stale_test_containers() { - if [ "$mode" != "test" ] || [ "$engine" != "podman" ]; then + if [ "$skip_demo_data" = true ]; then + echo "Skipping Samba demo-data load as requested" return 0 fi - local svc cname cstate - for svc in db app samba; do - cname="$(service_container_name "$svc")" - if [ -z "$cname" ]; then - continue - fi - if engine_exec inspect -f '{{.Id}}' "$cname" >/dev/null 2>&1; then - cstate="$(engine_exec inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || true)" - if [ "$cstate" != "running" ]; then - echo "Removing stale test container '$cname' (state: ${cstate:-unknown})" - engine_exec rm -f "$cname" >/dev/null 2>&1 || true + samba_container="$(current_service_container samba)" \ + || die "The iSkyLIMS test-data workflow requires the Samba service" + archive="$demo_data" + downloaded_archive="" + if [ -z "$archive" ]; then + command -v wget >/dev/null 2>&1 \ + || die "wget is required to download iSkyLIMS demo data" + downloaded_archive="$(mktemp /tmp/iskylims_demo_data.XXXXXX.tar.gz)" + archive="$downloaded_archive" + wget -O "$archive" \ + https://zenodo.org/record/8091169/files/iskylims_demo_data.tar.gz + fi + [ -f "$archive" ] || die "Demo-data archive not found: $archive" + + echo "Copying and extracting iSkyLIMS demo data in Samba" + engine_exec cp "$archive" "$samba_container:/mnt/iskylims_demo_data.tar.gz" + engine_exec exec "$samba_container" \ + tar -xf /mnt/iskylims_demo_data.tar.gz -C /mnt + engine_exec exec "$samba_container" sh -lc ' + for root in /mnt/test_ngs_data /mnt/Runs; do + if [ -d "$root" ]; then + find "$root" -type d -exec chmod o+rx {} + + find "$root" -type f -exec chmod o+r {} + fi - fi - done + done + ' + engine_exec exec "$samba_container" rm /mnt/iskylims_demo_data.tar.gz + [ -z "$downloaded_archive" ] || rm -f "$downloaded_archive" } -cleanup_stale_test_containers +action="install"; mode="production"; engine="docker"; git_revision="current" +install_conf=""; compose_file=""; compose_env_file="" +install_conf_map_entries=(); migration_script_before=(); migration_script_after=() +demo_data=""; demo_data_service=""; demo_data_map_entries=() +skip_demo_data=""; skip_test_data=""; skip_test_data_services=() +load_tables=false; skip_tables=false -if [ "$action" = "fix-permissions" ]; then - echo "Repairing production container bind mount and volume permissions..." - if ! mkdir -p "$apache_conf_path" "/var/log/local/relecov-iskylims/apache" "/var/log/local/relecov-iskylims/apps"; then - echo "Error: unable to create required host bind/log directories. Check APACHE_CONF_PATH and log directory permissions." >&2 - exit 1 - fi - prepare_django_settings_bind_mount "$django_settings_path" - prepare_host_bind_mount_permissions - write_compose_env_file - if try_resolve_app_container && [ "$(engine_exec inspect -f '{{.State.Running}}' "$app_container" 2>/dev/null)" = "true" ]; then - prepare_app_mount_permissions - echo "Done repairing host bind mounts and mounted app volumes." - else - echo "Host bind mount permissions repaired." - echo "The app container is not running, so named volumes were not repaired." - echo "Start containers with Compose, then rerun this action to repair mounted app volumes." - fi - exit 0 -fi +usage() { + cat <<'EOF' +Install, upgrade, or repair the application deployment. + +Options: + --action install|upgrade|fix-permissions + --test + --engine docker|podman + --git_revision + --install_conf First application service only. + --install_conf_map Repeat for application and add-on overrides. + --compose_file + --script_before + --script_after + --script + --tables Load initial tables; opt-in on upgrades. + --skip_tables Skip initial tables on a fresh install. + --demo_data Single-service compatibility option. + --demo_data_map Repeat for service-specific data imports. + --skip_demo_data + --skip_test_data + --skip_test_data_service Repeat to skip one service's test fixtures. + --help + --version +EOF +} +die() { echo "ERROR: $*" >&2; exit 1; } + +# 1. Parse the canonical outer-installer interface. +while (($#)); do + case "$1" in + --action) action="${2:-}"; shift 2 ;; + --test) mode="test"; shift ;; + --engine) engine="${2:-}"; shift 2 ;; + --git_revision) git_revision="${2:-}"; shift 2 ;; + --install_conf) install_conf="${2:-}"; shift 2 ;; + --install_conf_map) install_conf_map_entries+=("${2:-}"); shift 2 ;; + --compose_file) compose_file="${2:-}"; shift 2 ;; + --script_before) migration_script_before+=("${2:-}"); shift 2 ;; + --script_after|--script) migration_script_after+=("${2:-}"); shift 2 ;; + --tables) load_tables=true; skip_tables=false; shift ;; + --skip_tables) skip_tables=true; load_tables=false; shift ;; + --demo_data) demo_data="${2:-}"; shift 2 ;; + --demo_data_map) demo_data_map_entries+=("${2:-}"); shift 2 ;; + --skip_demo_data) skip_demo_data=true; shift ;; + --skip_test_data) skip_test_data=true; shift ;; + --skip_test_data_service) skip_test_data_services+=("${2:-}"); shift 2 ;; + --help) usage; exit 0 ;; + --version) echo "$APP_VERSION"; exit 0 ;; + *) die "Unknown option: $1" ;; + esac +done -print_local_source_diagnostics -print_existing_artifact_diagnostics -echo "Deploying containers (compose file: $compose_file) with a pre-staged app image and GIT_REVISION=$git_revision..." -if ! mkdir -p "$apache_conf_path" "/var/log/local/relecov-iskylims/apache" "/var/log/local/relecov-iskylims/apps"; then - echo "Error: unable to create required host bind/log directories. Check APACHE_CONF_PATH and log directory permissions." >&2 - exit 1 +# 2. Validate arguments before modifying deployment state. +[[ "$action" =~ ^(install|upgrade|fix-permissions)$ ]] || die "Invalid action: $action" +[[ "$engine" =~ ^(docker|podman)$ ]] || die "Invalid engine: $engine" +declare -A demo_data_by_service=() +if [ -n "$demo_data" ]; then + [ "${#install_services[@]}" -eq 1 ] || die "--demo_data is valid only for a single-service deployment; use --demo_data_map service,path" + demo_data_map_entries+=("${install_services[0]},$demo_data") fi -prepare_django_settings_bind_mount "$django_settings_path" -if [ -f "$repo_root/conf/iskylims_apache_reverse_proxy.conf" ]; then - render_apache_config \ - "$repo_root/conf/iskylims_apache_reverse_proxy.conf" \ - "$apache_conf_path/iskylims_apache_reverse_proxy.conf" +for mapping in "${demo_data_map_entries[@]}"; do + [[ "$mapping" == *,* ]] || die "Invalid --demo_data_map: $mapping" + service_name="${mapping%%,*}"; path="${mapping#*,}" + array_contains "$service_name" "${install_services[@]}" || die "Unknown demo-data service: $service_name" + [ -z "${demo_data_by_service[$service_name]+present}" ] || die "Duplicate --demo_data_map service: $service_name" + [ -n "$path" ] || die "Empty demo-data path for $service_name" + [ -f "$path" ] || die "Demo-data file not found for $service_name: $path" + demo_data_by_service["$service_name"]="$(cd "$(dirname "$path")" && pwd)/$(basename "$path")" +done +for service_name in "${skip_test_data_services[@]}"; do + array_contains "$service_name" "${install_services[@]}" || die "Unknown --skip_test_data_service: $service_name" +done +if [ "${#demo_data_by_service[@]}" -gt 0 ] && [ "$application_supports_test_data" != true ]; then + die "--demo_data_map is not implemented for $APPLICATION_NAME" fi -if [ -f "$repo_root/conf/iskylims_apache_logs.conf" ]; then - render_apache_config \ - "$repo_root/conf/iskylims_apache_logs.conf" \ - "$apache_conf_path/iskylims_apache_logs.conf" +if [ "${#demo_data_by_service[@]}" -gt 0 ] && [ "$action" != install ]; then + die "--demo_data_map is supported only with --action install" fi -if [ -f "$repo_root/conf/iskylims_apache_server-status.conf" ]; then - render_apache_config \ - "$repo_root/conf/iskylims_apache_server-status.conf" \ - "$apache_conf_path/iskylims_apache_server-status.conf" +if [ "${#demo_data_by_service[@]}" -gt 0 ] && [ "${skip_demo_data:-false}" = true ]; then + die "--demo_data_map cannot be combined with --skip_demo_data" fi -prepare_host_bind_mount_permissions -write_compose_env_file -INSTALL_TYPE="dep" GIT_REVISION="$git_revision" INSTALL_CONF="$install_conf_container" INSTALL_PATH="$install_path" APACHE_CONF_PATH="$apache_conf_path" DJANGO_SETTINGS_PATH="$django_settings_path" APP_UID="$app_uid" APP_GID="$app_gid" APP_SHELL="$app_shell" APP_PORT="$app_port" DJANGO_DEBUG="$django_debug" DB_CONN_MAX_AGE="$db_conn_max_age" WEB_CONCURRENCY="$web_concurrency" GUNICORN_THREADS="$gunicorn_threads" GUNICORN_TIMEOUT="$gunicorn_timeout" GUNICORN_KEEPALIVE="$gunicorn_keepalive" \ - compose_with_env_exec -f "$compose_file" build --no-cache \ - --build-arg INSTALL_TYPE="dep" \ - --build-arg GIT_REVISION="$git_revision" \ - --build-arg INSTALL_CONF="$install_conf_container" \ - --build-arg INSTALL_PATH="$install_path" \ - --build-arg APP_UID="$app_uid" \ - --build-arg APP_GID="$app_gid" \ - --build-arg APP_SHELL="$app_shell" -print_image_after_build -INSTALL_PATH="$install_path" APACHE_CONF_PATH="$apache_conf_path" DJANGO_SETTINGS_PATH="$django_settings_path" APP_UID="$app_uid" APP_GID="$app_gid" APP_SHELL="$app_shell" APP_PORT="$app_port" DJANGO_DEBUG="$django_debug" DB_CONN_MAX_AGE="$db_conn_max_age" WEB_CONCURRENCY="$web_concurrency" GUNICORN_THREADS="$gunicorn_threads" GUNICORN_TIMEOUT="$gunicorn_timeout" GUNICORN_KEEPALIVE="$gunicorn_keepalive" compose_with_env_exec -f "$compose_file" up -d - -echo "Waiting 20 seconds for starting database and web services..." -sleep 20 -ensure_app_running -print_container_source_diagnostics "Container diagnostics after startup:" -prepare_app_mount_permissions - -container_install_conf_path="$install_conf_container" -if [[ "$container_install_conf_path" != /* ]]; then - container_install_conf_path="$app_repo_path/$container_install_conf_path" +if [ "$mode" = test ] && [ "$action" = install ] \ + && [ "$application_supports_test_data" = true ]; then + skip_demo_data="${skip_demo_data:-false}" + skip_test_data="${skip_test_data:-false}" +elif [ "$action" = install ] && [ "${#demo_data_by_service[@]}" -gt 0 ] \ + && [ "$application_supports_test_data" = true ]; then + skip_demo_data="${skip_demo_data:-false}" + skip_test_data=true +else + skip_demo_data=true + skip_test_data=true fi -if ! engine_exec exec -it "$app_container" test -f "$container_install_conf_path"; then - echo "Copying install configuration into container at $container_install_conf_path" - engine_exec cp "$host_install_conf_path" "${app_container}:$container_install_conf_path" -fi +# 3. Resolve one protected configuration source per configured component. +cd "$script_dir" +declare -A install_conf_host_by_service=() +for service_name in "${configured_services[@]}"; do + install_conf_host_by_service["$service_name"]="$(default_service_install_conf "$service_name")" +done +if [ -n "$install_conf" ]; then install_conf_host_by_service["${install_services[0]}"]="$install_conf"; fi +for mapping in "${install_conf_map_entries[@]}"; do + [[ "$mapping" == *,* ]] || die "Invalid --install_conf_map: $mapping" + service_name="${mapping%%,*}"; path="${mapping#*,}" + array_contains "$service_name" "${configured_services[@]}" || die "Unknown mapped component: $service_name" + install_conf_host_by_service["$service_name"]="$path" +done +for service_name in "${configured_services[@]}"; do + path="${install_conf_host_by_service[$service_name]}" + [[ "$path" = /* ]] || path="$script_dir/$path" + [ -f "$path" ] || die "Configuration for $service_name not found: $path" + if [ "$mode" = production ] \ + && grep -Eq '^[A-Z0-9_]+=.*CHANGE_ME' "$path"; then + die "Production configuration for $service_name contains CHANGE_ME: $path" + fi + install_conf_host_by_service["$service_name"]="$(cd "$(dirname "$path")" && pwd)/$(basename "$path")" +done -script_args_before="" -if [ "$run_script_before" = true ]; then - for val in "${migration_script_before[@]}"; do - script_args_before+=" --script_before $(printf '%q' "$val")" +# 4. Select the engine, prepare host sources and validate the final Compose model. +set_engine "$engine" +compose_file="${compose_file:-docker-compose.$([ "$mode" = test ] && echo test || echo prod).yml}" +require_compose_file "$compose_file" +prepare_compose_environment +load_compose_environment_file "$compose_env_file" +prepare_application_host_sources +prepare_host_bind_source_permissions +deployment_compose -f "$compose_file" config \ + || die "Compose configuration validation failed: $compose_file" + +# 5. Dispatch permission-only repair without building or bootstrapping. +if [ "$action" = fix-permissions ]; then + for service_name in "${permission_services[@]}"; do + container_id="$(current_service_container "$service_name" 2>/dev/null || true)" + [ -z "$container_id" ] || prepare_running_container_mount_permissions "$service_name" "$container_id" done + echo "Permissions repaired without build or bootstrap." + exit 0 fi -script_args_after="" -if [ "$run_script" = true ]; then - for val in "${migration_script[@]}"; do - script_args_after+=" --script_after $(printf '%q' "$val")" +# 6. Build application services in declared order. Production builds use the +# engine directly: Django receives its settings as +# an ephemeral build secret, while React receives only its public VITE value. +# This avoids requiring Compose implementations to support build.secrets. +for service_name in "${install_services[@]}"; do + if [ "$mode" = test ]; then + deployment_compose -f "$compose_file" build --no-cache "$service_name" + continue + fi + context="$(service_build_context_dir "$service_name")" + dockerfile="$(service_dockerfile "$service_name")" + profile="$(service_profile "$service_name")" + if [ "$profile" = django ]; then + engine_build --no-cache --file "$context/$dockerfile" \ + --secret "id=install_conf,src=${install_conf_host_by_service[$service_name]}" \ + --build-arg GIT_REVISION="$git_revision" \ + --build-arg INSTALL_CONF="$(service_container_install_conf "$service_name")" \ + --build-arg USE_INSTALL_CONF_SECRET=true \ + --build-arg RENDER_DJANGO_SETTINGS=false \ + --build-arg APP_REPO_PATH="$(service_repo_path "$service_name")" \ + --build-arg APP_INSTALL_PATH="$(service_install_path "$service_name")" \ + --build-arg APP_PORT="$(service_environment_value "$service_name" APP_PORT)" \ + --build-arg APP_UID="$(service_uid "$service_name")" \ + --build-arg APP_GID="$(service_gid "$service_name")" \ + --tag "$(service_image_name "$service_name")" "$context" + else + vite_api_url="$(service_environment_value "$service_name" VITE_API_BASE_URL)" + engine_build --no-cache --file "$context/$dockerfile" \ + --build-arg GIT_REVISION="$git_revision" \ + --build-arg VITE_API_BASE_URL="$vite_api_url" \ + --tag "$(service_image_name "$service_name")" "$context" + fi +done +# Build add-on images through Compose so their declared build arguments and +# add-on-owned Dockerfiles remain the single source of truth. +for service_name in "${addon_build_services[@]}"; do + deployment_compose -f "$compose_file" build --no-cache "$service_name" +done +# 7. Recreate and start the complete topology from one Compose invocation so +# freshly built images and the current configuration are deployed consistently. +# Named volumes and bind-mounted persistent data are preserved. +deployment_compose -f "$compose_file" up -d --force-recreate + +# 8. Wait for every application service readiness contract. +for service_name in "${install_services[@]}"; do + container_id="$(current_service_container "$service_name")" + [ -n "$container_id" ] || die "Unable to resolve $service_name container" + ensure_service_running "$service_name" "$container_id" >/dev/null + readiness_path="$(service_readiness_path "$service_name")" + deadline=$((SECONDS + 120)) + until engine_exec exec "$container_id" test -f "$readiness_path"; do + ((SECONDS < deadline)) || { engine_exec logs --tail 200 "$container_id"; die "$service_name readiness timeout"; } + sleep 2 done -fi - -if [ "$action" = "upgrade" ]; then - echo "Running install.sh bootstrap inside the container (upgrade mode)" - engine_exec exec -it "$app_container" bash -c "cd $app_repo_path && bash install.sh --bootstrap upgrade --git_revision \"$git_revision\" --conf \"$install_conf_container\" --tables --skip_apache_restart$script_args_before$script_args_after" -else - echo "Running install.sh bootstrap inside the container (install mode)" - engine_exec exec -it "$app_container" bash -c "cd $app_repo_path && bash install.sh --bootstrap install --git_revision \"$git_revision\" --conf \"$install_conf_container\" --skip_apache_restart$script_args_before$script_args_after" -fi - -print_container_source_diagnostics "Container diagnostics after bootstrap:" +done -if ! engine_exec exec -it "$app_container" test -f "$install_path/manage.py"; then - echo "Error: $install_path/manage.py not found after bootstrap. Showing logs:" - engine_exec logs --tail 200 "$app_container" - exit 1 -fi +# 9. Repair running mounts for applications and selected add-ons. +for service_name in "${permission_services[@]}"; do + container_id="$(current_service_container "$service_name")" + prepare_running_container_mount_permissions "$service_name" "$container_id" +done -if [ "$skip_test_data" = false ]; then - engine_exec exec -it "$app_container" python3 manage.py loaddata test/test_data.json - engine_exec exec -it "$app_container" python3 manage.py shell -c " -from django.contrib.auth.models import Group, User -admin = User.objects.get(username='admin') -admin.groups.add( - Group.objects.get(name='WetlabManager'), - Group.objects.get(name='ServiceManager'), -) -print('admin groups:', list(admin.groups.values_list('name', flat=True))) -" -else - echo "Skipping test data fixtures as requested" -fi +# 10. Bootstrap only application profiles that require runtime bootstrap. +for service_name in "${install_services[@]}"; do + container_id="$(current_service_container "$service_name")" + bootstrap_service "$service_name" "$container_id" "$action" || die "$service_name bootstrap failed" +done -if [ "$skip_demo_data" = false ] && service_exists "samba"; then - echo "Downloading and copying test files to the Samba container" - if [ "$demo_data" == "false" ]; then - wget https://zenodo.org/record/8091169/files/iskylims_demo_data.tar.gz - demo_data="./iskylims_demo_data.tar.gz" - fi - engine_exec cp "$demo_data" samba:/mnt - engine_exec exec -it samba tar -xf /mnt/iskylims_demo_data.tar.gz -C /mnt - # Ensure extracted demo data can be traversed/read through SMB by non-owner users. - engine_exec exec -it samba sh -lc ' - for root in /mnt/test_ngs_data /mnt/Runs; do - if [ -d "$root" ]; then - find "$root" -type d -exec chmod o+rx {} + - find "$root" -type f -exec chmod o+r {} + - fi +# 11. Load application-owned data for a fresh test install, or for an explicit +# production --demo_data_map request. Production never imports data implicitly. +if [ "$action" = install ] \ + && [ "$application_supports_test_data" = true ] \ + && { [ "$mode" = test ] || [ "${#demo_data_by_service[@]}" -gt 0 ]; }; then + if [ "${#demo_data_by_service[@]}" -gt 0 ]; then + for service_name in "${install_services[@]}"; do + [ -n "${demo_data_by_service[$service_name]+present}" ] || continue + demo_data_service="$service_name"; demo_data="${demo_data_by_service[$service_name]}"; skip_test_data=true + load_test_deployment_data "$demo_data_service" "$demo_data" done - ' - - echo "Deleting compressed test file" - engine_exec exec -it samba rm /mnt/iskylims_demo_data.tar.gz - - if [ "$demo_data" == "false" ]; then - rm -f "$demo_data" + else + demo_data_service="${install_services[0]}"; demo_data="" + array_contains "$demo_data_service" "${skip_test_data_services[@]}" && skip_test_data=true + load_test_deployment_data "$demo_data_service" "$demo_data" fi -else - echo "Skipping Samba demo data load (flag enabled or service not present)" -fi - -echo "Skipping crontab add/start (cron is managed by the container entrypoint)" - -dns_url="" -local_ip="" -if [ -f "$host_install_conf_path" ]; then - dns_url=$(grep -E "^DNS_URL=" "$host_install_conf_path" | tail -n 1 | cut -d= -f2- | sed "s/^['\"]//;s/['\"]$//") - local_ip=$(grep -E "^LOCAL_SERVER_IP=" "$host_install_conf_path" | tail -n 1 | cut -d= -f2- | sed "s/^['\"]//;s/['\"]$//") -fi - -access_urls=() -if [ -n "$dns_url" ] && [ "$dns_url" != "*" ]; then - access_urls+=("http://${dns_url}:${app_port}") -fi -if [ -n "$local_ip" ] && [ "$local_ip" != "*" ]; then - access_urls+=("http://${local_ip}:${app_port}") -fi -if [ ${#access_urls[@]} -eq 0 ]; then - access_urls+=("http://localhost:${app_port}") fi -echo "You can now access iSkyLIMS via: ${access_urls[*]}" +# 12. Execute the common smoke dispatcher with generated profile checks. +smoke_args=(--engine "$engine" --compose_file "$compose_file" --env_file "$compose_env_file") +[ "$mode" = test ] && smoke_args+=(--test) +bash "$script_dir/scripts/smoke_test.sh" "${smoke_args[@]}" +echo "$action completed successfully for $APPLICATION_NAME." +print_service_summary diff --git a/deployment/lib/container/common.sh b/deployment/lib/container/common.sh new file mode 100644 index 000000000..eb4aa5d79 --- /dev/null +++ b/deployment/lib/container/common.sh @@ -0,0 +1,883 @@ +#!/usr/bin/env bash + +# BU-ISCIII shared container installer library. +# +# This file is centrally managed. Applications must not edit vendored copies. +# Application container_install.sh scripts provide the global variables used by +# these helpers (engine, mode, compose_env_file, ENGINE_CMD and COMPOSE_CMD). + +BU_ISCIII_CONTAINER_LIB_VERSION="0.1.0" + +# Select and validate the requested container engine and its Compose frontend. +# This keeps Docker/Podman detection identical in every outer installer. +set_engine() { + if [ "$engine" = "docker" ]; then + if ! command -v docker >/dev/null 2>&1; then + echo "docker not found. Install docker or use --engine podman." + exit 1 + fi + ENGINE_CMD=("docker") + COMPOSE_CMD=("docker" "compose") + else + if ! command -v podman >/dev/null 2>&1; then + echo "podman not found. Install podman or use --engine docker." + exit 1 + fi + ENGINE_CMD=("podman") + if command -v podman-compose >/dev/null 2>&1; then + COMPOSE_CMD=("podman-compose") + elif podman compose version >/dev/null 2>&1; then + COMPOSE_CMD=("podman" "compose") + else + echo "podman compose not available. Install podman-compose or use --engine docker." + exit 1 + fi + fi +} + +# Run a container-engine command using the engine selected by set_engine. +engine_exec() { + "${ENGINE_CMD[@]}" "$@" +} + +# Build an image with the selected engine. Docker explicitly enables BuildKit +# because Dockerfile secret mounts are unavailable in the legacy builder. +engine_build() { + if [ "${engine:-docker}" = "docker" ]; then + DOCKER_BUILDKIT=1 engine_exec build "$@" + else + engine_exec build "$@" + fi +} + +# Run a Compose command using the frontend selected by set_engine. +compose_exec() { + "${COMPOSE_CMD[@]}" "$@" +} + +# Run Compose with the generated production environment file when available. +# Test Compose files intentionally continue to use their normal environment. +compose_with_env_exec() { + if [ "${mode:-production}" = "production" ] \ + && [ -n "${compose_env_file:-}" ] \ + && [ -f "$compose_env_file" ]; then + compose_exec --env-file "$compose_env_file" "$@" + else + compose_exec "$@" + fi +} + +# Return the image ID associated with one service in an explicit Compose file. +# Both arguments are required so the helper has no hidden application globals. +# Arguments: compose file path, service name. +compose_service_image_id() { + local compose_path="$1" + local service_name="$2" + compose_with_env_exec -f "$compose_path" images -q "$service_name" 2>/dev/null \ + | tail -n 1 +} + +# Fail early when the selected Compose file does not exist. +# Arguments: Compose file path. +require_compose_file() { + local compose_path="$1" + if [ ! -f "$compose_path" ]; then + echo "Compose file '$compose_path' not found" >&2 + return 1 + fi +} + +# Validate the fully interpolated Compose model after application environment +# preparation. Arguments: Compose file path. +validate_compose_configuration() { + local compose_path="$1" + require_compose_file "$compose_path" || return 1 + if ! compose_with_env_exec -f "$compose_path" config --quiet; then + echo "Compose configuration validation failed: $compose_path" >&2 + return 1 + fi +} + +# Return a repository's full or short HEAD without printing diagnostics. +# Arguments: repository path, optional format (`full` or `short`). +repository_revision() { + local repository_path="$1" + local format="${2:-full}" + command -v git >/dev/null 2>&1 || return 1 + git -C "$repository_path" rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || return 1 + if [ "$format" = "short" ]; then + git -C "$repository_path" rev-parse --short HEAD + else + git -C "$repository_path" rev-parse HEAD + fi +} + +# Print the actual local checkout and the revision requested by the operator. +# Arguments: display label, repository path, expected branch/tag/commit/current. +print_repository_diagnostics() { + local label="$1" + local repository_path="$2" + local expected_revision="$3" + local actual_hash="" + + echo "$label" + echo " requested revision: $expected_revision" + actual_hash="$(repository_revision "$repository_path" full)" || true + if [ -n "$actual_hash" ]; then + echo " local HEAD: $(git -C "$repository_path" log -1 --oneline)" + echo " local HEAD hash: $actual_hash" + else + echo " local git metadata unavailable" + fi +} + +# Print an image ID before a build. The caller resolves the ID from an explicit +# image name or Compose service and passes it in, keeping lookup policy separate. +# Arguments: display label, image ID/name (empty means no existing image). +print_image_before_diagnostics() { + local label="$1" + local image_reference="$2" + echo "$label" + if [ -n "$image_reference" ]; then + echo " image before build: $image_reference" + else + echo " image before build: none" + fi +} + +# Compare explicit pre-build and post-build image IDs/names. +# Arguments: display label, previous image reference, current image reference. +print_image_after_diagnostics() { + local label="$1" + local previous_reference="$2" + local current_reference="$3" + echo "$label" + if [ -z "$current_reference" ]; then + echo " image after build: not found" + elif [ -n "$previous_reference" ] && [ "$previous_reference" = "$current_reference" ]; then + echo " image after build: $current_reference" + echo " image id check: unchanged" + elif [ -n "$previous_reference" ]; then + echo " image after build: $current_reference" + echo " image id check: changed" + else + echo " image after build: $current_reference" + echo " image id check: created" + fi +} + +# Print the standard repository and existing-image report before a build. +# Arguments: label, repository path, requested revision, existing image ID/name. +print_prebuild_diagnostics() { + local label="$1" + local repository_path="$2" + local expected_revision="$3" + local image_reference="$4" + + print_repository_diagnostics "$label source:" "$repository_path" "$expected_revision" + print_image_before_diagnostics "$label image before build:" "$image_reference" +} + +# Compare a container's source checkout with an explicit expected revision. +# Arguments: label, container ID/name, repository path inside the container, +# expected full hash, expected short hash. +print_container_repository_diagnostics() { + local label="$1" + local container_id="$2" + local repository_path="$3" + local expected_hash="$4" + local expected_short="$5" + local container_hash="" + local container_short="" + + echo "$label" + container_hash="$(engine_exec exec "$container_id" sh -lc " + if [ -d '$repository_path/.git' ]; then + cd '$repository_path' && git rev-parse HEAD + fi + " 2>/dev/null | tail -n 1)" + container_short="$(engine_exec exec "$container_id" sh -lc " + if [ -d '$repository_path/.git' ]; then + cd '$repository_path' && git rev-parse --short HEAD + fi + " 2>/dev/null | tail -n 1)" + if [ -n "$container_hash" ]; then + echo " container source HEAD hash: $container_hash" + fi + if [ -n "$expected_hash" ] && [ -n "$container_hash" ]; then + if [ "$expected_hash" = "$container_hash" ]; then + echo " HEAD check: OK local=$expected_short container=$container_short" + else + echo " HEAD check: MISMATCH local=$expected_short container=$container_short" + fi + fi + engine_exec exec "$container_id" sh -lc " + echo ' $repository_path HEAD:' + if [ -d '$repository_path/.git' ]; then + cd '$repository_path' && git log -1 --oneline + else + echo 'not a git checkout' + fi + " || true +} + +# Copy a host file, entering Podman's user namespace when mapped ownership +# prevents an ordinary host-side copy. +copy_with_podman_fallback() { + local src="$1" + local dst="$2" + local tmp_dst="" + + # Replace the destination through its parent directory instead of opening + # an existing bind-mounted file in place. Containers may have changed that + # file's ownership to an unmapped/root identity, while the operator still + # owns the parent directory and is therefore allowed to replace it. The + # same-directory rename also prevents readers from seeing a partial file. + if tmp_dst="$(mktemp "${dst}.tmp.XXXXXX" 2>/dev/null)" \ + && cp "$src" "$tmp_dst" 2>/dev/null \ + && mv -f "$tmp_dst" "$dst" 2>/dev/null; then + return 0 + fi + [ -z "$tmp_dst" ] || rm -f "$tmp_dst" 2>/dev/null || true + if [ "$engine" = "podman" ] && podman unshare cp "$src" "$dst"; then + return 0 + fi + echo "Failed to copy '$src' to '$dst'" >&2 + return 1 +} + +# Run one ownership/mode operation through rootful Docker. Every bind source is +# resolved first and the host root is always rejected. +docker_host_path_operation() { + local operation="$1" + local value="$2" + shift 2 + local path resolved_path + + [[ "$operation" =~ ^(chown|chmod)$ ]] || return 2 + for path in "$@"; do + resolved_path="$(readlink -f -- "$path" 2>/dev/null || true)" + if [ -z "$resolved_path" ] || [ "$resolved_path" = / ]; then + echo "Refusing Docker $operation fallback for unsafe path: $path" >&2 + return 1 + fi + engine_exec run --rm --user 0 \ + --volume "$resolved_path:/target:z" \ + --entrypoint "/usr/bin/$operation" \ + registry.access.redhat.com/ubi9/ubi-minimal:latest \ + -R "$value" /target || return 1 + done +} + +# Change host-path modes, entering the selected engine's ownership context when +# an ordinary host operation is not permitted. +chmod_with_engine_fallback() { + local mode_value="$1" + shift + + if chmod "$mode_value" "$@" 2>/dev/null; then + return 0 + fi + if [ "$engine" = "podman" ] && podman unshare chmod "$mode_value" "$@"; then + return 0 + fi + if [ "$engine" = "docker" ]; then + [[ "$mode_value" =~ ^[0-7]{3,4}$ ]] || { + echo "Docker mode fallback requires a numeric mode: $mode_value" >&2 + return 1 + } + docker_host_path_operation chmod "$mode_value" "$@" && return 0 + fi + echo "Failed to chmod $mode_value: $*" >&2 + return 1 +} + +# Change host-path ownership recursively. Rootless Podman can operate on mapped +# IDs through its user namespace. Rootful Docker can perform the same change +# through a tightly scoped bind mount without requiring host sudo access. +chown_with_engine_fallback() { + local owner="$1" + shift + + if chown -R "$owner" "$@" 2>/dev/null; then + return 0 + fi + if [ "$engine" = "podman" ] && podman unshare chown -R "$owner" "$@"; then + return 0 + fi + if [ "$engine" = "docker" ]; then + [[ "$owner" =~ ^[0-9]+(:[0-9]+)?$ ]] || { + echo "Docker ownership fallback requires a numeric UID or UID:GID: $owner" >&2 + return 1 + } + docker_host_path_operation chown "$owner" "$@" && return 0 + fi + echo "Failed to chown $owner: $*" >&2 + return 1 +} + +# Apply an application-owned host permission specification consistently. +# Each argument is one `path|owner|mode` entry. Use `-` when ownership or mode +# must remain unchanged. Missing paths are skipped so optional bind sources can +# be declared alongside required ones without wrapper-owned condition loops. +apply_host_permission_spec() { + local entry path owner mode extra + + for entry in "$@"; do + IFS='|' read -r path owner mode extra <<< "$entry" + if [ -z "$path" ] || [ -z "$owner" ] || [ -z "$mode" ] || [ -n "$extra" ]; then + echo "Invalid host permission entry '$entry'; expected path|owner|mode." >&2 + return 1 + fi + [ -e "$path" ] || continue + if [ "$owner" != "-" ]; then + chown_with_engine_fallback "$owner" "$path" || return 1 + fi + if [ "$mode" != "-" ]; then + chmod_with_engine_fallback "$mode" "$path" || return 1 + fi + done +} + +# Create and repair application-writable directories as seen inside a running +# container. Each argument is one `path|owner|mode` entry. Ownership and mode +# are applied recursively because mounted directory contents may pre-exist. +# Arguments: container ID/name, followed by directory specifications. +apply_container_directory_permission_spec() { + local container_id="$1" + shift + local entry path owner mode extra + + for entry in "$@"; do + IFS='|' read -r path owner mode extra <<< "$entry" + if [ -z "$path" ] || [ -z "$owner" ] || [ -z "$mode" ] || [ -n "$extra" ]; then + echo "Invalid container directory permission entry '$entry'; expected path|owner|mode." >&2 + return 1 + fi + engine_exec exec --user 0 "$container_id" sh -c ' + path="$1" + owner="$2" + mode="$3" + mkdir -p "$path" + chown -R "$owner" "$path" + chmod -R "$mode" "$path" + ' _ "$path" "$owner" "$mode" || return 1 + done +} + +# Copy an installation configuration into a running container and immediately +# restrict it to the application identity. The destination parent must already +# exist as part of the staged application source. +# Arguments: container ID/name, host file, container path, UID, GID. +stage_container_runtime_config() { + local container_id="$1" + local host_path="$2" + local container_path="$3" + local application_uid="$4" + local application_gid="$5" + + if [ ! -f "$host_path" ]; then + echo "Runtime installation configuration not found: $host_path" >&2 + return 1 + fi + if [ -z "$container_path" ] || [ "$container_path" = "/" ]; then + echo "Invalid runtime installation configuration destination: '$container_path'" >&2 + return 1 + fi + engine_exec cp "$host_path" "${container_id}:$container_path" || return 1 + engine_exec exec --user 0 "$container_id" \ + chown "$application_uid:$application_gid" "$container_path" || return 1 + engine_exec exec --user 0 "$container_id" chmod 0600 "$container_path" +} + +# Remove the exact temporary runtime configuration staged for bootstrap. +# Arguments: container ID/name, container path. +remove_container_runtime_config() { + local container_id="$1" + local container_path="$2" + + if [ -z "$container_path" ] || [ "$container_path" = "/" ]; then + echo "Refusing to remove invalid runtime configuration path: '$container_path'" >&2 + return 1 + fi + engine_exec exec --user 0 "$container_id" rm -f -- "$container_path" +} + +# Run an application's smoke-test script with the standard deployment context. +# Arguments: script, mode, engine, Compose file, optional environment file, +# followed by application-specific smoke-test arguments. +run_standard_smoke_test() { + local smoke_script="$1" + local deployment_mode="$2" + local container_engine="$3" + local compose_path="$4" + local environment_path="$5" + shift 5 + local -a smoke_args=(--engine "$container_engine" --compose_file "$compose_path") + + if [ ! -f "$smoke_script" ]; then + echo "Smoke-test script not found: $smoke_script" >&2 + return 1 + fi + if [ "$deployment_mode" = "test" ]; then + smoke_args+=(--test) + elif [ -n "$environment_path" ]; then + smoke_args+=(--env_file "$environment_path") + fi + echo "Running deployment smoke test: $smoke_script" + bash "$smoke_script" "${smoke_args[@]}" "$@" +} + +# Convert a URL or host:port value into a valid Apache ServerName host. +normalize_apache_server_name() { + local value="$1" + + value="${value#http://}" + value="${value#https://}" + value="${value%%/*}" + value="${value%%:*}" + if [ -z "$value" ] || [ "$value" = "*" ]; then + value="localhost" + fi + echo "$value" +} + +# Escape a value before placing it in the replacement side of a sed command. +sed_replacement_escape() { + printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' +} + +# Escape a literal value before placing it in the search side of a sed command. +sed_search_escape() { + printf '%s' "$1" | sed -e 's/[][\\.^$*+?{}()|]/\\&/g' +} + +# Read one shell-style installation setting in an isolated Bash process. +# Isolation prevents configuration assignments from changing installer state. +read_install_conf_value() { + local key="$1" + local file="$2" + [ -f "$file" ] || return 0 + bash -c ' + set -a + . "$1" + key="$2" + printf "%s" "${!key-}" + ' _ "$file" "$key" +} + +# Return the first non-empty value among compatible setting names. This allows +# the shared renderer to bridge established RELECOV names and the standard +# scaffold names without duplicating application configuration files. +read_install_conf_first() { + local file="$1" + shift + local key value + for key in "$@"; do + value="$(read_install_conf_value "$key" "$file")" + if [ -n "$value" ]; then + echo "$value" + return 0 + fi + done +} + +# Return an environment override, then a configuration value, then a default. +# Arguments: setting name, configuration file, default value. +config_value_or_default() { + local key="$1" + local file="$2" + local default_value="$3" + local env_value="${!key:-}" + local config_value="" + + if [ -n "$env_value" ]; then + echo "$env_value" + return 0 + fi + config_value="$(read_install_conf_value "$key" "$file")" + if [ -n "$config_value" ]; then + echo "$config_value" + else + echo "$default_value" + fi +} + +# Return a required setting using the same environment-over-file precedence. +# This is used while building the generated Compose environment so a missing +# secret or host path fails before Compose changes deployment state. +config_value() { + local key="$1" + local file="$2" + local value + value="$(config_value_or_default "$key" "$file" "")" + if [ -z "$value" ]; then + echo "Required setting $key is missing from environment and $file" >&2 + return 1 + fi + printf '%s\n' "$value" +} + +# Normalize a bind-mount setting to a file path. Operators may configure either +# the final file or its parent directory; the supplied default is app-owned. +normalize_bind_file_path() { + local value="$1" + local default_path="$2" + local filename="$3" + local expected_suffix=".${filename##*.}" + + if [ -z "$value" ]; then + echo "$default_path" + elif [ -d "$value" ] || [[ "$value" = */ ]] || [[ "$value" != *"$expected_suffix" ]]; then + echo "${value%/}/$filename" + else + echo "$value" + fi +} + +# Create a configuration file from a plain-text template. Arguments are: +# source template, destination file, destination mode, then repeating literal +# PLACEHOLDER VALUE pairs. Every placeholder occurrence is replaced by its +# value; values are not executed or sourced as shell code. The completed file +# is installed through the Docker/Podman-compatible host file helpers. +render_config_template() { + local src="$1" + local dst="$2" + local file_mode="$3" + shift 3 + local tmp_file token value escaped_token escaped_value + + if [ $(( $# % 2 )) -ne 0 ]; then + echo "render_config_template requires PLACEHOLDER VALUE pairs" >&2 + return 2 + fi + + tmp_file="$(mktemp)" + cp "$src" "$tmp_file" + while [ "$#" -gt 0 ]; do + token="$1" + value="$2" + shift 2 + escaped_token="$(sed_search_escape "$token")" + escaped_value="$(sed_replacement_escape "$value")" + sed -E -i "s|$escaped_token|$escaped_value|g" "$tmp_file" + done + if copy_with_podman_fallback "$tmp_file" "$dst" \ + && chmod_with_engine_fallback "$file_mode" "$dst"; then + rm -f "$tmp_file" + return 0 + fi + rm -f "$tmp_file" + return 1 +} + +# Render every ${UPPER_CASE_VARIABLE} found in a repository-owned configuration +# source from the already loaded deployment environment. This keeps application +# topology editable in normal configuration files while ensuring Compose mounts +# completed files with no unresolved deployment placeholders. +# Arguments: source configuration, destination file, destination mode. +render_environment_config_template() { + local src="$1" + local dst="$2" + local file_mode="$3" + local token variable + local -a replacements=() + + [ -f "$src" ] || { + echo "Configuration source not found: $src" >&2 + return 1 + } + while IFS= read -r token; do + [ -n "$token" ] || continue + variable="${token#\$\{}" + variable="${variable%\}}" + if ! [[ -v "$variable" ]]; then + echo "Required template variable $variable is not set for $src" >&2 + return 1 + fi + replacements+=("$token" "${!variable}") + done < <(grep -oE '\$\{[A-Z][A-Z0-9_]*\}' "$src" | sort -u || true) + + render_config_template "$src" "$dst" "$file_mode" "${replacements[@]}" +} + +# Quote one value for literal use in a Compose environment file. Single-quoted +# dotenv values are not interpolated; embedded apostrophes are escaped. +compose_environment_quote() { + local value="$1" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + echo "Compose environment values must not contain newlines." >&2 + return 1 + fi + value="${value//\'/\\\'}" + printf "'%s'" "$value" +} + +# Generate one protected Compose environment file from application settings. +# Arguments: output path, settings-source array name, explicit-value array name. +# +# Settings sources contain `PREFIX|path` entries. Every uppercase assignment in +# the file is emitted as PREFIX_KEY, or as KEY when PREFIX is empty. Explicit +# values contain `KEY|value` entries for derived values such as image names and +# Git revisions. Duplicate output keys, invalid names, and multiline values are +# rejected. The result is installed atomically with mode 0600. +write_compose_environment_file() { + local output_path="$1" + local sources_name="$2" + local values_name="$3" + local -n settings_sources_ref="$sources_name" + local -n explicit_values_ref="$values_name" + local output_dir temporary_file entry prefix settings_path key output_key value quoted_value + local -A emitted_keys=() + + output_dir="$(dirname "$output_path")" + [ -d "$output_dir" ] || { + echo "Compose environment output directory not found: $output_dir" >&2 + return 1 + } + temporary_file="$(mktemp "$output_dir/.compose-env.XXXXXX")" || return 1 + chmod 0600 "$temporary_file" || { rm -f "$temporary_file"; return 1; } + + for entry in "${settings_sources_ref[@]}"; do + if [[ "$entry" != *"|"* ]]; then + echo "Invalid Compose settings source '$entry'; expected PREFIX|path." >&2 + rm -f "$temporary_file" + return 1 + fi + prefix="${entry%%|*}" + settings_path="${entry#*|}" + if [ -n "$prefix" ] && [[ ! "$prefix" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then + echo "Invalid Compose environment prefix: $prefix" >&2 + rm -f "$temporary_file" + return 1 + fi + if [ ! -f "$settings_path" ]; then + echo "Compose settings source not found: $settings_path" >&2 + rm -f "$temporary_file" + return 1 + fi + + while IFS= read -r key; do + [ -n "$key" ] || continue + if [ -n "$prefix" ]; then output_key="${prefix}_${key}"; else output_key="$key"; fi + if [ -n "${emitted_keys[$output_key]:-}" ]; then + echo "Duplicate Compose environment variable: $output_key" >&2 + rm -f "$temporary_file" + return 1 + fi + value="$(read_install_conf_value "$key" "$settings_path")" + quoted_value="$(compose_environment_quote "$value")" \ + || { rm -f "$temporary_file"; return 1; } + printf '%s=%s\n' "$output_key" "$quoted_value" >> "$temporary_file" \ + || { rm -f "$temporary_file"; return 1; } + emitted_keys["$output_key"]=1 + done < <(sed -nE \ + 's/^[[:space:]]*(export[[:space:]]+)?([A-Z_][A-Z0-9_]*)[[:space:]]*=.*/\2/p' \ + "$settings_path" | sort -u) + done + + for entry in "${explicit_values_ref[@]}"; do + if [[ "$entry" != *"|"* ]]; then + echo "Invalid explicit Compose value '$entry'; expected KEY|value." >&2 + rm -f "$temporary_file" + return 1 + fi + output_key="${entry%%|*}" + value="${entry#*|}" + if [[ ! "$output_key" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then + echo "Invalid Compose environment variable name: $output_key" >&2 + rm -f "$temporary_file" + return 1 + fi + if [ -n "${emitted_keys[$output_key]:-}" ]; then + echo "Duplicate Compose environment variable: $output_key" >&2 + rm -f "$temporary_file" + return 1 + fi + quoted_value="$(compose_environment_quote "$value")" \ + || { rm -f "$temporary_file"; return 1; } + printf '%s=%s\n' "$output_key" "$quoted_value" >> "$temporary_file" \ + || { rm -f "$temporary_file"; return 1; } + emitted_keys["$output_key"]=1 + done + + mv -f "$temporary_file" "$output_path" || { rm -f "$temporary_file"; return 1; } + chmod 0600 "$output_path" +} + +# Load the protected dotenv file generated by write_compose_environment_file. +# Direct image builds and host preparation then consume exactly the same +# prefixed deployment values that Compose interpolates later. +load_compose_environment_file() { + local file="$1" + local disable_allexport_after="true" + [ -f "$file" ] || { + echo "Compose environment file not found: $file" >&2 + return 1 + } + [[ $- == *a* ]] && disable_allexport_after="false" + set -a + # shellcheck disable=SC1090 + source "$file" + [ "$disable_allexport_after" = false ] || set +a +} + +# Validate an installation configuration and, when it is outside the selected +# build context, copy it to a temporary context file. All results are explicit +# named output variables; applications still choose service contexts/defaults. +# Arguments: input path, base directory, build context, service, mode, +# host-path output name, context-relative output name, +# temporary-path output name. +prepare_install_configuration() { + local input_path="$1" + local base_directory="$2" + local build_context="$3" + local service_name="$4" + local deployment_mode="$5" + local -n host_path_result="$6" + local -n context_path_result="$7" + local -n temporary_path_result="$8" + local resolved_host_path="" + local resolved_context="" + local temporary_name="" + + host_path_result="" + context_path_result="" + temporary_path_result="" + + if [ ! -d "$build_context" ]; then + echo "Build context directory '$build_context' for service '$service_name' not found" >&2 + return 1 + fi + resolved_context="$(cd "$build_context" && pwd -P)" + + if [[ "$input_path" = /* ]]; then + resolved_host_path="$input_path" + else + resolved_host_path="${base_directory%/}/$input_path" + fi + if [ ! -f "$resolved_host_path" ]; then + echo "Install configuration '$input_path' for service '$service_name' not found" >&2 + return 1 + fi + resolved_host_path="$(cd "$(dirname "$resolved_host_path")" && pwd -P)/$(basename "$resolved_host_path")" + + if [ "$deployment_mode" = "production" ] \ + && [[ "$(basename "$resolved_host_path")" != *settings*.txt ]]; then + echo "Production configuration filenames must match *settings*.txt so .dockerignore excludes them from COPY: $resolved_host_path" >&2 + return 1 + fi + + if [[ "$resolved_host_path" != "$resolved_context/"* ]]; then + if [ "$deployment_mode" = "production" ]; then + temporary_name=".tmp_docker_install_conf_${service_name}_$$.txt" + else + # Test configuration is explicitly non-sensitive and must remain in + # the context because test builds do not receive a build secret. + temporary_name=".tmp_docker_test_install_conf_${service_name}_$$.txt" + fi + temporary_path_result="$resolved_context/$temporary_name" + echo "Copying $resolved_host_path into temporary file $temporary_path_result for service '$service_name'." >&2 + cp "$resolved_host_path" "$temporary_path_result" || return 1 + resolved_host_path="$temporary_path_result" + fi + + host_path_result="$resolved_host_path" + context_path_result="${resolved_host_path#$resolved_context/}" +} + +# Remove explicitly supplied temporary files. It is intended for EXIT traps; +# callers retain ownership of the list and no directory is ever removed. +cleanup_files() { + local file + for file in "$@"; do + if [ -n "$file" ] && [ -f "$file" ]; then + rm -f "$file" + fi + done +} + +# Return success when the first argument exactly matches one of the remaining +# arguments. An empty value list is valid and returns failure. +# Arguments: value to find, zero or more candidate values. +array_contains() { + local wanted="$1" + shift + local candidate + for candidate in "$@"; do + if [ "$candidate" = "$wanted" ]; then + return 0 + fi + done + return 1 +} + +# Test whether a service is declared in the selected Compose model. A stopped +# service still exists, so this deliberately uses config rather than ps. +service_exists() { + compose_with_env_exec -f "$compose_file" config --services 2>/dev/null \ + | grep -Fxq "$1" +} + +# Resolve a service to a container name/ID, supporting application legacy names +# first. Otherwise inspect every container returned by the selected Compose +# project and match its service label. Listing the project first avoids both a +# cross-project "app" collision and the unsupported `ps -q SERVICE` syntax in +# podman-compose 1.0.x. +resolve_service_container() { + local service_name="$1" + local service_container="" + local container_name="" + + # Applications may define service_container_name to support explicit legacy + # container_name values. Compose labels are the generic fallback. + if declare -F service_container_name >/dev/null 2>&1; then + container_name="$(service_container_name "$service_name")" + fi + if [ -n "$container_name" ] \ + && engine_exec inspect -f '{{.Id}}' "$container_name" >/dev/null 2>&1; then + service_container="$container_name" + elif [ -n "${compose_file:-}" ]; then + local candidate candidate_service + while IFS= read -r candidate; do + [ -n "$candidate" ] || continue + candidate_service="$(engine_exec inspect -f \ + '{{ index .Config.Labels "com.docker.compose.service" }}' \ + "$candidate" 2>/dev/null || true)" + if [ "$candidate_service" = "$service_name" ]; then + service_container="$candidate" + break + fi + done < <(compose_with_env_exec -f "$compose_file" ps -q 2>/dev/null || true) + fi + # Retain the label query as a compatibility fallback for callers that do + # not have a Compose file in scope (including older application wrappers). + if [ -z "$service_container" ]; then + service_container="$(engine_exec ps -a \ + --filter "label=com.docker.compose.service=${service_name}" \ + --format '{{.ID}}' | head -n 1)" + fi + if [ -z "$service_container" ]; then + echo "Error: unable to resolve container ID for service '$service_name'." >&2 + return 1 + fi + echo "$service_container" +} + +# Fail with useful logs unless a resolved service container is running. +ensure_service_running() { + local service_name="$1" + local service_container="$2" + + if ! engine_exec inspect -f '{{.State.Running}}' "$service_container" >/dev/null 2>&1; then + echo "Error: service '$service_name' container does not exist." >&2 + exit 1 + fi + if [ "$(engine_exec inspect -f '{{.State.Running}}' "$service_container")" != "true" ]; then + echo "Error: service '$service_name' container is not running. Showing logs:" >&2 + engine_exec logs --tail 200 "$service_container" >&2 || true + exit 1 + fi + echo "$service_container" +} diff --git a/deployment/lib/container/django.sh b/deployment/lib/container/django.sh new file mode 100644 index 000000000..b0ece4344 --- /dev/null +++ b/deployment/lib/container/django.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# BU-ISCIII shared host-side Django deployment helpers. +# +# These functions belong to container_install.sh, not the in-container +# install.sh: Compose requires the settings bind-mount source to exist on the +# host before the application container can start. The inner installer cannot +# create or repair that host path because it runs after mounts are established. + +BU_ISCIII_DJANGO_CONTAINER_LIB_VERSION="0.2.0" + +# Generate a cryptographically random Django-compatible SECRET_KEY without +# requiring Django itself to be installed on the deployment host. It belongs in +# this profile because its alphabet and purpose are Django-specific. +generate_django_secret_key() { + if command -v python3 >/dev/null 2>&1; then + python3 -c "import secrets; print(''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)') for _ in range(50)))" + else + LC_ALL=C tr -dc 'A-Za-z0-9!@#$%^&*(-_=+)' < /dev/urandom | head -c 50 + printf "\n" + fi +} + +# Convert conventional configuration boolean spellings to a Python literal. +# Arguments: setting name and raw value. +django_python_boolean() { + local setting_name="$1" + local value="$2" + + case "${value,,}" in + true|1|yes|on) printf 'True\n' ;; + false|0|no|off) printf 'False\n' ;; + *) + echo "$setting_name must be a boolean value." >&2 + return 1 + ;; + esac +} + +# Render a Django settings template from the standard BU-ISCIII installation +# keys while preserving an existing non-placeholder SECRET_KEY. +# Arguments: template path, destination settings path, install-conf path. +render_django_settings_file() { + local template_path="$1" + local settings_path="$2" + local install_conf_path="$3" + local secret_line="" + local template_secret_line="" + local django_debug="" + local email_use_tls="" + local token variable value python_literal + local -a application_replacements=() + + if [ -f "$settings_path" ]; then + secret_line="$(grep -E "^SECRET_KEY[[:space:]]*=" "$settings_path" | tail -n 1 || true)" + fi + if [ -z "$secret_line" ] || [[ "$secret_line" =~ SECRET_KEY[[:space:]]*=[[:space:]]*SECRET ]]; then + secret_line="SECRET_KEY = '$(generate_django_secret_key)'" + fi + + template_secret_line="$(grep -E "^SECRET_KEY[[:space:]]*=" "$template_path" | head -n 1)" + if [ -z "$template_secret_line" ]; then + echo "Django template '$template_path' has no SECRET_KEY assignment." >&2 + return 1 + fi + django_debug="$(django_python_boolean DJANGO_DEBUG \ + "$(config_value_or_default DJANGO_DEBUG "$install_conf_path" false)")" \ + || return 1 + email_use_tls="$(django_python_boolean EMAIL_USE_TLS \ + "$(read_install_conf_value EMAIL_USE_TLS "$install_conf_path")")" \ + || return 1 + while IFS= read -r token; do + [ -n "$token" ] || continue + variable="${token#settingsconf_}" + value="$(read_install_conf_value "$variable" "$install_conf_path")" || return 1 + python_literal="$(python3 -c \ + 'import json, sys; print(json.dumps(sys.argv[1]))' "$value")" || return 1 + application_replacements+=("$token" "$python_literal") + done < <(grep -oE 'settingsconf_[A-Z][A-Z0-9_]*' "$template_path" | sort -u || true) + render_config_template "$template_path" "$settings_path" 0664 \ + "$template_secret_line" "$secret_line" \ + djangouser "$(read_install_conf_first "$install_conf_path" DB_USER)" \ + djangopass "$(read_install_conf_value DB_PASSWORD "$install_conf_path")" \ + djangohost "$(read_install_conf_value DB_HOST "$install_conf_path")" \ + djangoport "$(read_install_conf_value DB_PORT "$install_conf_path")" \ + djangodbname "$(read_install_conf_value DB_NAME "$install_conf_path")" \ + emailhostserver "$(read_install_conf_value EMAIL_HOST "$install_conf_path")" \ + emailport "$(read_install_conf_value EMAIL_PORT "$install_conf_path")" \ + emailhostuser "$(read_install_conf_value EMAIL_HOST_USER "$install_conf_path")" \ + emailhostpassword "$(read_install_conf_value EMAIL_HOST_PASSWORD "$install_conf_path")" \ + emailhosttls "$email_use_tls" \ + djangodebug "$django_debug" \ + djangoallowedhosts "$(read_install_conf_value DJANGO_ALLOWED_HOSTS "$install_conf_path")" \ + djangocsrftrustedorigins "$(read_install_conf_value DJANGO_CSRF_TRUSTED_ORIGINS "$install_conf_path")" \ + dbconnmaxage "$(config_value_or_default DB_CONN_MAX_AGE "$install_conf_path" 0)" \ + "${application_replacements[@]}" +} + +# Ensure the production settings bind source exists and reflects the selected +# database configuration before Compose starts the application container. +# Arguments: template path, destination settings path, install-conf path. +prepare_django_settings_bind_mount() { + local template_path="$1" + local settings_path="$2" + local install_conf_path="$3" + + [ "${mode:-production}" = "production" ] || return 0 + if [ -d "$settings_path" ]; then + echo "DJANGO_SETTINGS_PATH must resolve to a file path, but '$settings_path' is a directory." >&2 + return 1 + fi + + mkdir -p "$(dirname "$settings_path")" + # Always rerender so application-template and deployment-setting changes + # reach upgrades. render_django_settings_file preserves the existing + # non-placeholder SECRET_KEY, and render_config_template installs atomically. + render_django_settings_file "$template_path" "$settings_path" "$install_conf_path" + chmod_with_engine_fallback 0664 "$settings_path" +} + +# Apply ownership and mode to a Django settings file visible inside a running +# container. The caller supplies the exact application path and identity; +# writable-directory policy remains application-owned. +# Arguments: container ID/name, settings path, application UID, application GID. +prepare_django_container_settings_permissions() { + local container_id="$1" + local settings_path="$2" + local application_uid="$3" + local application_gid="$4" + + [ -n "$settings_path" ] || return 0 + engine_exec exec --user 0 "$container_id" sh -c ' + settings_path="$1" + owner="$2" + if [ -f "$settings_path" ]; then + chown "$owner" "$settings_path" + chmod 0664 "$settings_path" + fi + ' _ "$settings_path" "$application_uid:$application_gid" +} diff --git a/deployment/project.json b/deployment/project.json new file mode 100644 index 000000000..285ec07ac --- /dev/null +++ b/deployment/project.json @@ -0,0 +1,31 @@ +{ + "APP_NAME": "iSkyLIMS", + "APP_SLUG": "iskylims", + "DESCRIPTION": "Laboratory information management system for wet-lab and dry-lab sequencing workflows.", + "REPOSITORY_URL": "https://github.com/BU-ISCIII/iSkyLIMS.git", + "DEFAULT_BRANCH": "main", + "PYTHON_VERSION": "3.11", + "TIMEZONE": "Europe/Madrid", + "SERVICES": { + "iskylims": { + "PROFILE": "django", + "BUILD_CONTEXT": ".", + "DOCKERFILE": "Dockerfile", + "IMAGE": "iskylims:local", + "INSTALL_CONF": "conf/docker_production_settings.txt", + "TEST_INSTALL_CONF": "conf/docker_test_settings.txt", + "PROJECT_MODULE": "iskylims" + } + }, + "ADDONS": { + "apache": { + "CONFIG_SERVICE": "iskylims" + }, + "samba": { + "CONFIG_SERVICE": "iskylims", + "MODES": [ + "test" + ] + } + } +} diff --git a/deployment_health/README.md b/deployment_health/README.md new file mode 100644 index 000000000..7ed4b80da --- /dev/null +++ b/deployment_health/README.md @@ -0,0 +1,17 @@ +# Django deployment health endpoint + +Register the centrally managed health URL module once in the application's +root `urls.py`: + +```python +from django.urls import include, path + +urlpatterns = [ + path("health/", include("deployment_health.urls")), + # Application-owned routes follow. +] +``` + +Do not add `deployment_health` to `INSTALLED_APPS`: it has no models, templates +or application startup hooks. Compose and the deployment smoke test require +`GET /health/` to return a successful response. diff --git a/deployment_health/__init__.py b/deployment_health/__init__.py new file mode 100644 index 000000000..ca536c2f8 --- /dev/null +++ b/deployment_health/__init__.py @@ -0,0 +1 @@ +"""Framework-neutral deployment health endpoint for Django services.""" diff --git a/deployment_health/urls.py b/deployment_health/urls.py new file mode 100644 index 000000000..c62284efb --- /dev/null +++ b/deployment_health/urls.py @@ -0,0 +1,9 @@ +"""URLs for the standard deployment health endpoint.""" + +from django.urls import path + +from .views import health_check + +urlpatterns = [ + path("", health_check, name="deployment-health"), +] diff --git a/deployment_health/views.py b/deployment_health/views.py new file mode 100644 index 000000000..ee88a3853 --- /dev/null +++ b/deployment_health/views.py @@ -0,0 +1,8 @@ +"""Minimal health views used by container orchestration and smoke tests.""" + +from django.http import JsonResponse + + +def health_check(_request): + """Report HTTP process readiness without querying external dependencies.""" + return JsonResponse({"status": "ok"}) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 42575950b..c98b7eab6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,67 +1,96 @@ -version: '3.8' - services: - apache: - container_name: iskylims_apache - restart: unless-stopped - image: registry.access.redhat.com/ubi9/httpd-24 - depends_on: - - app - ports: - - "8080:8080" - volumes: - - /etc/localtime:/etc/localtime:ro - - /usr/share/zoneinfo:/usr/share/zoneinfo:ro - - ${APACHE_CONF_PATH:-/opt/iskylims/conf}/iskylims_apache_reverse_proxy.conf:/etc/httpd/conf.d/01-iskylims.conf:ro - - ${APACHE_CONF_PATH:-/opt/iskylims/conf}/iskylims_apache_logs.conf:/etc/httpd/conf.d/00-logformat.conf:ro - - ${APACHE_CONF_PATH:-/opt/iskylims/conf}/iskylims_apache_server-status.conf:/etc/httpd/conf.d/02-server-status.conf:ro - - /var/log/local/iskylims/apache:/var/log/httpd - - iskylims_static:${INSTALL_PATH:-/opt/iskylims}/static:ro - - iskylims_documents:${INSTALL_PATH:-/opt/iskylims}/documents:ro - networks: - - iskylims_net - - app: - container_name: iskylims_app - restart: unless-stopped + # BEGIN BU-ISCIII SERVICE: iskylims (django) + iskylims: + image: "${ISKYLIMS_IMAGE:-iskylims:local}" build: - context: . + context: "." + dockerfile: "Dockerfile" args: - INSTALL_TYPE: ${INSTALL_TYPE:-dep} - GIT_REVISION: ${GIT_REVISION:-main} - INSTALL_CONF: ${INSTALL_CONF:-conf/docker_production_settings.txt} - APP_UID: ${APP_UID:-1212} - APP_GID: ${APP_GID:-1212} - APP_SHELL: ${APP_SHELL:-/sbin/nologin} - INSTALL_PATH: ${INSTALL_PATH:-/opt/iskylims} - expose: - - "${APP_PORT:-8001}" + GIT_REVISION: ${GIT_REVISION:-current} + # Fixed internal path used only while install.sh reads the build secret. + INSTALL_CONF: "conf/.runtime_install_settings.txt" + USE_INSTALL_CONF_SECRET: "true" + RENDER_DJANGO_SETTINGS: "false" + APP_UID: ${ISKYLIMS_APP_UID:?ISKYLIMS_APP_UID is required} + APP_GID: ${ISKYLIMS_APP_GID:?ISKYLIMS_APP_GID is required} + APP_PORT: ${ISKYLIMS_APP_PORT:?ISKYLIMS_APP_PORT is required} + APP_REPO_PATH: ${ISKYLIMS_REPO_PATH:?ISKYLIMS_REPO_PATH is required} + APP_INSTALL_PATH: ${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required} + restart: unless-stopped + user: "${ISKYLIMS_APP_UID:?ISKYLIMS_APP_UID is required}:${ISKYLIMS_APP_GID:?ISKYLIMS_APP_GID is required}" environment: - DJANGO_SETTINGS_MODULE: iskylims.settings - DJANGO_DEBUG: ${DJANGO_DEBUG:-false} APP_MODE: prod - APP_PORT: ${APP_PORT:-8001} - DB_CONN_MAX_AGE: ${DB_CONN_MAX_AGE:-60} - WEB_CONCURRENCY: ${WEB_CONCURRENCY:-2} - GUNICORN_THREADS: ${GUNICORN_THREADS:-2} - GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-300} - GUNICORN_KEEPALIVE: ${GUNICORN_KEEPALIVE:-5} - INSTALL_PATH: ${INSTALL_PATH:-/opt/iskylims} - user: "${APP_UID:-1005}:${APP_GID:-1005}" + INSTALL_PATH: ${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required} + APP_INSTALL_PATH: ${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required} + APP_PORT: ${ISKYLIMS_APP_PORT:?ISKYLIMS_APP_PORT is required} + PROJECT_MODULE: "iskylims" + DB_HOST: ${ISKYLIMS_DB_HOST:?ISKYLIMS_DB_HOST is required} + DB_PORT: ${ISKYLIMS_DB_PORT:?ISKYLIMS_DB_PORT is required} + DB_NAME: ${ISKYLIMS_DB_NAME:?ISKYLIMS_DB_NAME is required} + DB_USER: ${ISKYLIMS_DB_USER:?ISKYLIMS_DB_USER is required} + DB_PASSWORD: ${ISKYLIMS_DB_PASSWORD:?ISKYLIMS_DB_PASSWORD is required} + DJANGO_SECRET_KEY: ${ISKYLIMS_DJANGO_SECRET_KEY:?ISKYLIMS_DJANGO_SECRET_KEY is required} + DJANGO_ALLOWED_HOSTS: ${ISKYLIMS_DJANGO_ALLOWED_HOSTS:?ISKYLIMS_DJANGO_ALLOWED_HOSTS is required} volumes: - - /etc/localtime:/etc/localtime:ro - - /usr/share/zoneinfo:/usr/share/zoneinfo:ro - - /var/log/local/relecov-iskylims/apps/:${INSTALL_PATH:-/opt/iskylims}/logs - - ${DJANGO_SETTINGS_PATH:-/opt/iskylims/iskylims/settings.py}:${INSTALL_PATH:-/opt/iskylims}/iskylims/settings.py - - iskylims_documents:${INSTALL_PATH:-/opt/iskylims}/documents - - iskylims_static:${INSTALL_PATH:-/opt/iskylims}/static + - iskylims_documents:${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required}/documents:z + - ${ISKYLIMS_HOST_LOG_PATH:?ISKYLIMS_HOST_LOG_PATH is required}:${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required}/logs:Z + - ${ISKYLIMS_DJANGO_SETTINGS_PATH:?ISKYLIMS_DJANGO_SETTINGS_PATH is required}:${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required}/iskylims/settings.py:Z + - iskylims_static:${ISKYLIMS_INSTALL_PATH:?ISKYLIMS_INSTALL_PATH is required}/static:z + healthcheck: + test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ['APP_PORT'] + '/health/', timeout=3)"] + interval: 10s + timeout: 5s + retries: 12 + ports: + - "127.0.0.1:${ISKYLIMS_APP_PORT:?ISKYLIMS_APP_PORT is required}:${ISKYLIMS_APP_PORT:?ISKYLIMS_APP_PORT is required}" + # Docker Engine on Linux needs this explicit mapping. Podman also supports + # host-gateway and conventionally exposes host.docker.internal itself, so + # host-based dependencies can use one hostname with either engine. extra_hosts: - "host.docker.internal:host-gateway" - networks: - - iskylims_net + networks: [deployment_net] + # END BU-ISCIII SERVICE: iskylims + # BEGIN BU-ISCIII ADDON: apache + iskylims-apache: + image: registry.access.redhat.com/ubi9/httpd-24:latest + restart: unless-stopped + depends_on: + iskylims: + condition: service_healthy + ports: + # Public binding can be restricted to loopback when another host proxy + # terminates TLS before forwarding traffic to this container. + - "${APACHE_BIND_HOST:?APACHE_BIND_HOST is required}:${APACHE_PORT:?APACHE_PORT is required}:8080" + expose: + - "8080" + healthcheck: + test: ["CMD-SHELL", "bash -ec ' Branch, tag, commit, or current (default). + --conf Normalized installation settings file. + --render-settings Render Django settings during staging. + --settings-output Override the rendered settings destination. + --tables Load conf/first_install_tables.json. + --skip_tables Never load the initial fixture. + --script_before Repeatable pre-migrate django-extensions hook. + --script_after Repeatable post-migrate hook. + --script Alias for --script_after. + --docker Deprecated alias for --skip_apache_restart. + --skip_apache_restart Do not restart a host Apache service. + --help + --version Examples: - Install iskylims only dep - sudo $0 --install dep - - Install only iSkyLIMS app - $0 --install app - - Upgrade using develop code - $0 --upgrade full --git_revision develop - - Upgrade running migration script and update initial tables - $0 --upgrade full --script --tables - - Stage application files during a container image build - $0 --stage install --git_revision main --conf conf/docker_production_settings.txt - - Bootstrap database/static using an already staged container image - $0 --bootstrap upgrade --git_revision main --conf conf/docker_production_settings.txt - - Make adjustments for apps renaming in upgrade 2.3.0 to 2.3.1 - $0 --upgrade full --ren_app --script --tables - - Upgrade running pre/post migration scripts: - $0 --upgrade app --script_before --script_after + ./install.sh --install full --conf conf/docker_test_settings.txt --tables + ./install.sh --upgrade app --git_revision v2.0.0 --script_before prepare_v2 + ./install.sh --stage install --conf conf/docker_test_settings.txt --render-settings + ./install.sh --bootstrap upgrade --conf /tmp/runtime_install_settings.txt EOF } -# log: write timestamped log entries to stdout. -_log_compose_entry() { - local level="$1"; shift - local message="$*" - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - printf "%s [%s] %s" "$timestamp" "$level" "$message" -} - -log() { - local level="$1"; shift - local message="$*" - local entry - entry="$(_log_compose_entry "$level" "$message")" - printf "%s\n" "$entry" -} - -# db_check: verifies connectivity to the configured MySQL instance using mysqladmin/mysqlshow. -db_check(){ - log "INFO" "Checking database connectivity against $DB_SERVER_IP:$DB_PORT" - local mysqladmin_bin - local mysqlshow_bin - mysqladmin_bin="$(command -v mysqladmin || command -v mariadb-admin || true)" - mysqlshow_bin="$(command -v mysqlshow || command -v mariadb-show || true)" - - if [ -z "$mysqladmin_bin" ] || [ -z "$mysqlshow_bin" ]; then - log "ERROR" "mysql client tools not found (mysqladmin/mysqlshow or mariadb-admin/mariadb-show)." - exit 1 - fi - - "$mysqladmin_bin" -h $DB_SERVER_IP -u$DB_USER -p$DB_PASS -P$DB_PORT processlist > /dev/null - - if ! [ $? -eq 0 ]; then - log "ERROR" "Unable to connect to database. Check if your database is running and accessible" - exit 1 - fi - RESULT=`"$mysqlshow_bin" --user=$DB_USER --password=$DB_PASS --host=$DB_SERVER_IP --port=$DB_PORT | grep -o $DB_NAME` - - if ! [ "$RESULT" == "$DB_NAME" ] ; then - log "ERROR" "iskylims database is not defined yet" - log "ERROR" "Create iskylims database on your mysql server and run again the installation script" - exit 1 - fi -} - -# apache_check: ensures apache/httpd service is running depending on distribution. -apache_check(){ - if [[ $linux_distribution == "Ubuntu" ]]; then - if ! pidof apache2 > /dev/null ; then - log "WARN" "Apache Server is down... Trying to restart Apache" - systemctl restart apache2.service - sleep 10 - if pidof apache2 > /dev/null ; then - log "INFO" "Apache Server is up" - else - log "ERROR" "Unable to start Apache" - log "ERROR" "Solve the issue with Apache server and run again the installation script" - exit 1 - fi - fi - elif [[ $linux_distribution == "CentOs" || $linux_distribution == "RedHatEnterprise" ]]; then - if ! pidof httpd > /dev/null ; then - log "WARN" "Apache Server is down... Trying to restart Apache" - systemctl restart httpd - sleep 10 - if pidof httpd > /dev/null ; then - log "INFO" "Apache Server is up" - else - log "ERROR" "Unable to start Apache" - log "ERROR" "Solve the issue with Apache server and run again the installation script" - exit 1 - fi - fi - fi -} - -# python_check: confirm required Python version is available in PYTHON_BIN_PATH. -python_check(){ - python_version=$(su -c $PYTHON_BIN_PATH --version $user 2>&1) - if [[ $python_version == "" ]]; then - log "ERROR" "Python3 is not found in your system" - log "ERROR" "Solve the issue with Python and run again the installation script" - exit 1 - fi - p_version=$(echo $python_version | cut -d"." -f2) - if (( $p_version < 7 )); then - log "ERROR" "Application requires at least version 3.7.x of Python3" - log "ERROR" "Solve the issue with python and run again the installation script" - exit 1 - fi -} - -# root_check: enforce running privileged sections as root. -root_check(){ - if [[ $EUID -ne 0 ]]; then - log "ERROR" "Exiting installation. This script must be run as root" - exit 1 - fi -} - -generate_django_secret_key(){ - "$PYTHON_BIN_PATH" -c "import secrets; print(''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)') for _ in range(50)))" -} - -sed_replacement_escape(){ - printf '%s' "$1" | sed -e 's/[\&|]/\\&/g' -} - -# update_settings_and_urls: rewrite Django settings and urls with deployment values. -update_settings_and_urls(){ - log "INFO" "Updating settings.py and urls.py with deployment values" - local project_dir="$INSTALL_PATH/$PROJECT_NAME" - local secret_line="" - local tmp_settings="" - - if [ -f "$project_dir/settings.py" ]; then - secret_line="$(grep -E "^SECRET_KEY[[:space:]]*=" "$project_dir/settings.py" | tail -n 1)" - fi - if [ -z "$secret_line" ] || [[ "$secret_line" =~ SECRET_KEY[[:space:]]*=[[:space:]]*SECRET ]]; then - secret_line="SECRET_KEY = '$(generate_django_secret_key)'" - fi +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +info() { printf 'INFO: %s\n' "$*"; } +command_required() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"; } + +while (($#)); do + case "$1" in + --git_revision) GIT_REVISION="${2:-}"; shift 2 ;; + --conf) INSTALL_CONF="${2:-}"; shift 2 ;; + --render-settings) RENDER_SETTINGS="true"; shift ;; + --settings-output) SETTINGS_OUTPUT="${2:-}"; shift 2 ;; + --stage|--bootstrap) + WORKFLOW="${1#--}" + [[ "${2:-}" =~ ^(install|upgrade)$ ]] || die "$1 requires install or upgrade" + ACTION="$2"; OPERATION_SCOPE="app"; shift 2 + ;; + --install|--upgrade) + ACTION="${1#--}"; WORKFLOW="standard" + [[ "${2:-}" =~ ^(full|dep|app)$ ]] || die "$1 requires full, dep, or app" + OPERATION_SCOPE="$2"; shift 2 + ;; + --script_before) [[ -n "${2:-}" ]] || die "$1 requires a value"; SCRIPT_BEFORE+=("$2"); shift 2 ;; + --script_after|--script) [[ -n "${2:-}" ]] || die "$1 requires a value"; SCRIPT_AFTER+=("$2"); shift 2 ;; + --tables) LOAD_TABLES="true"; shift ;; + --skip_tables) SKIP_TABLES="true"; LOAD_TABLES="false"; shift ;; + --docker|--skip_apache_restart) SKIP_APACHE_RESTART="true"; shift ;; + --help) usage; exit 0 ;; + --version) echo "$APP_VERSION"; exit 0 ;; + *) die "Unknown option: $1" ;; + esac +done - tmp_settings="$(mktemp)" - cp conf/template_settings.txt "$tmp_settings" - cp conf/urls.py "$project_dir" +# A fresh installation loads an application-owned initial fixture by default. +# Upgrades remain opt-in through --tables; --skip_tables is an explicit escape +# hatch for recovery or externally restored databases. +if [[ "$ACTION" == "install" && "$SKIP_TABLES" == "false" \ + && -f "$install_script_dir/conf/first_install_tables.json" ]]; then + LOAD_TABLES="true" +fi - sed -i \ - -e "s|^SECRET_KEY.*|$(sed_replacement_escape "$secret_line")|" \ - -e "s|djangouser|$(sed_replacement_escape "$DB_USER")|g" \ - -e "s|djangopass|$(sed_replacement_escape "$DB_PASS")|g" \ - -e "s|djangohost|$(sed_replacement_escape "$DB_SERVER_IP")|g" \ - -e "s|djangoport|$(sed_replacement_escape "$DB_PORT")|g" \ - -e "s|djangodbname|$(sed_replacement_escape "$DB_NAME")|g" \ - -e "s|emailhostserver|$(sed_replacement_escape "$EMAIL_HOST_SERVER")|g" \ - -e "s|emailport|$(sed_replacement_escape "$EMAIL_PORT")|g" \ - -e "s|emailhostuser|$(sed_replacement_escape "$EMAIL_HOST_USER")|g" \ - -e "s|emailhostpassword|$(sed_replacement_escape "$EMAIL_HOST_PASSWORD")|g" \ - -e "s|emailhosttls|$(sed_replacement_escape "$EMAIL_USE_TLS")|g" \ - -e "s|localserverip|$(sed_replacement_escape "$LOCAL_SERVER_IP")|g" \ - -e "s|localhost|$(sed_replacement_escape "$DNS_URL")|g" \ - "$tmp_settings" +[[ "$WORKFLOW" != "stage" || ${#SCRIPT_BEFORE[@]} -eq 0 && ${#SCRIPT_AFTER[@]} -eq 0 ]] \ + || die "Migration scripts cannot run during the stage workflow" +[[ -f "$INSTALL_CONF" ]] || die "Configuration not found: $INSTALL_CONF" +if [[ "$INSTALL_CONF" != /* ]]; then + INSTALL_CONF="$(cd "$(dirname "$INSTALL_CONF")" && pwd)/$(basename "$INSTALL_CONF")" +fi +if [[ "$WORKFLOW" != "stage" ]] && grep -Eq "^[A-Z0-9_]+=.*CHANGE_ME" "$INSTALL_CONF"; then + die "Configuration still contains CHANGE_ME values" +fi +# shellcheck disable=SC1090 +source "$INSTALL_CONF" +: "${INSTALL_PATH:?INSTALL_PATH is required}" +: "${PROJECT_MODULE:?PROJECT_MODULE is required}" +[[ "$PROJECT_MODULE" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \ + || die "PROJECT_MODULE must be a valid Python package name" +: "${PYTHON_BIN_PATH:?PYTHON_BIN_PATH is required}" +: "${DB_HOST:?DB_HOST is required}" +: "${DB_PORT:?DB_PORT is required}" +: "${DB_NAME:?DB_NAME is required}" +: "${DB_USER:?DB_USER is required}" +: "${DB_PASSWORD:?DB_PASSWORD is required}" +REQUIRED_MODULES="${REQUIRED_MODULES:-}" +MIGRATION_MODULES="${MIGRATION_MODULES:-}" + +if [[ "$RENDER_SETTINGS" == "auto" ]]; then + [[ "$WORKFLOW" == "standard" ]] && RENDER_SETTINGS="true" || RENDER_SETTINGS="false" +fi - cp "$tmp_settings" "$project_dir/settings.py" - rm -f "$tmp_settings" +remember_git_ref() { + git -C "$install_script_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + INITIAL_GIT_REF="$(git -C "$install_script_dir" symbolic-ref --quiet --short HEAD \ + || git -C "$install_script_dir" rev-parse HEAD)" } -# restore_git_ref: reset repository to branch/tag/commit active before script ran. restore_git_ref() { - echo "Restoring to initial git reference: $initial_git_ref" - git checkout "$initial_git_ref" --quiet -} - -# load_tables: wrapper to call Django loaddata with optional verbosity. -load_tables() { - # Function parameters - local data_file="${1:-conf/first_install_tables.json}" - local verbose="${2:-false}" - - # Check if the file exists - if [[ ! -f "$data_file" ]]; then - echo "Error: The data file '$data_file' does not exist." - return 1 - fi - - # Conditional message based on verbose mode - if [[ "$verbose" == true ]]; then - echo "Loading pre-filled tables from file: $data_file" - fi - - # Load pre-filled tables - python manage.py loaddata "$data_file" - if [[ $? -eq 0 ]]; then - echo "Tables loaded successfully from '$data_file'." - else - echo "Error loading tables from '$data_file'." - return 1 - fi - - if [[ "$verbose" == true ]]; then - echo "Table loading process completed." - fi -} - -# ensure_git_safe_directory: avoid Git "dubious ownership" failures in containerized installs. -ensure_git_safe_directory() { - local repo_dir - repo_dir="$(pwd -P)" - - if [ -d "$repo_dir/.git" ] || [ -f "$repo_dir/.git" ]; then - git config --global --add safe.directory "$repo_dir" >/dev/null 2>&1 || true - git config --system --add safe.directory "$repo_dir" >/dev/null 2>&1 || true - fi -} - -# Ensure to recover current git branch/tag/SHA on script exit -ensure_git_safe_directory -initial_git_ref=$(git rev-parse --abbrev-ref HEAD || git rev-parse HEAD) -trap restore_git_ref EXIT - -#================================================================ -#SET TEMINAL COLORS -#================================================================ -YELLOW='\033[0;33m' -WHITE='\033[0;37m' -CYAN='\033[0;36m' -BLUE='\033[0;34m' -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' -ORANGE='\033[0;33m' - -# log_section: print a visually separated header in both console and log. -log_section() { - local message="$1" - log "INFO" "$message" - printf "\n\n%s\n" "${YELLOW}------------------${NC}" - printf "%b\n" "${YELLOW}${message}${NC}" - printf "%s\n\n" "${YELLOW}------------------${NC}" -} - -# log_info: convenience helper for blue info messages (console only). -log_info() { - printf "%b\n" "${BLUE}$(_log_compose_entry "INFO" "$1")${NC}" + [[ -n "$INITIAL_GIT_REF" ]] || return 0 + git -C "$install_script_dir" checkout --quiet "$INITIAL_GIT_REF" || \ + printf 'WARNING: could not restore git revision %s\n' "$INITIAL_GIT_REF" >&2 } -# log_warn: emit warning text in cyan for terminal visibility. -log_warn() { - printf "%b\n" "${CYAN}$(_log_compose_entry "WARN" "$1")${NC}" -} - -# log_error: emit error text in red for terminal visibility. -log_error() { - printf "%b\n" "${RED}$(_log_compose_entry "ERROR" "$1")${NC}" -} - -# abort_install: log an error and exit with optional status. -abort_install() { - log_error "$1" - exit "${2:-1}" -} - -chown_if_root() { - if [ "$EUID" -eq 0 ]; then - chown "$@" - else - log_warn "Skipping chown (requires root): chown $*" - fi -} - -ensure_file_exists() { - local file_path="$1" - local friendly_name="${2:-$1}" - if [ ! -f "$file_path" ]; then - abort_install "Required file '$friendly_name' not found." - fi -} - -# load_install_config: source the selected install_settings file. -load_install_config() { - ensure_file_exists "$conf" "$conf" - # shellcheck disable=SC1090 - . "$conf" -} - -# checkout_git_revision: ensure desired git revision exists and check it out safely. checkout_git_revision() { - if [[ "$git_branch" == "current" ]]; then - printf "${YELLOW}Using copied local working tree without git checkout.${NC}\n" - return 0 - fi - if git rev-parse --verify "$git_branch" >/dev/null 2>&1; then - if [[ $git_branch != $initial_git_ref ]]; then - local local_changes - local_changes=$(git status --porcelain) - if [[ -n $local_changes ]]; then - abort_install "Unable to switch to $git_branch. Commit or stash local changes first." - fi - printf "${YELLOW}Switching to revision %s.${NC}\n" "$git_branch" - git checkout "$git_branch" --quiet - else - printf "${YELLOW}Using current revision: '%s'.${NC}\n" "$git_branch" + [[ "$GIT_REVISION" != "current" ]] || return 0 + [[ -n "$INITIAL_GIT_REF" ]] || die "Cannot select $GIT_REVISION: source has no Git metadata" + git -C "$install_script_dir" rev-parse --verify "${GIT_REVISION}^{commit}" >/dev/null 2>&1 \ + || die "Git revision is not available locally: $GIT_REVISION" + [[ -z "$(git -C "$install_script_dir" status --porcelain)" ]] \ + || die "Commit or stash local changes before selecting $GIT_REVISION" + git -C "$install_script_dir" checkout --quiet "$GIT_REVISION" +} + +check_python() { + command_required "$PYTHON_BIN_PATH" + "$PYTHON_BIN_PATH" -c 'import sys; raise SystemExit(sys.version_info < (3, 10))' \ + || die "Python 3.10 or newer is required" +} + +check_required_modules() { + local module + [[ -f "$install_script_dir/conf/urls.py" ]] \ + || die "Django URL configuration is missing: conf/urls.py" + grep -Fq 'deployment_health.urls' \ + "$install_script_dir/conf/urls.py" \ + || die "conf/urls.py must include deployment_health.urls for the /health/ endpoint" + for module in $REQUIRED_MODULES; do + [[ -e "$install_script_dir/$module" ]] || die "Required application module is missing: $module" + done +} + +check_database() { + # Prefer the MySQL CLI when available; container images use mysqlclient's + # MySQLdb module from the application virtual environment. Podman network + # aliases can become resolvable shortly after the container process starts, + # so retry this existing readiness check for up to 60 seconds. + local deadline=$((SECONDS + 60)) + while true; do + if command -v mysql >/dev/null 2>&1; then + MYSQL_PWD="$DB_PASSWORD" mysql --host="$DB_HOST" --port="$DB_PORT" \ + --user="$DB_USER" --database="$DB_NAME" --execute='SELECT 1' \ + >/dev/null 2>&1 && return 0 + elif "$INSTALL_PATH/virtualenv/bin/python" - "$DB_HOST" "$DB_PORT" "$DB_USER" "$DB_PASSWORD" "$DB_NAME" >/dev/null 2>&1 <<'PY' +import sys +import MySQLdb +connection = MySQLdb.connect(host=sys.argv[1], port=int(sys.argv[2]), + user=sys.argv[3], passwd=sys.argv[4], db=sys.argv[5]) +connection.close() +PY + then + return 0 fi - else - abort_install "Git reference $git_branch is not defined in ${PWD}." - fi -} - -# check_requirements: run Python/DB/Apache/root validations before install/upgrade. -check_requirements() { - log_section "Checking main requirements" - python_check - log_info "Valid version of Python" - if [[ "$operation_scope" == "full" || "$operation_scope" == "app" ]]; then - db_check - log_info "Successful check for database" - if [ "$restart_apache" = true ]; then - apache_check - log_info "Successful check for apache" - fi - fi - - if [ "$install_type" == "full" ] || [ "$install_type" == "dep" ] || [ "$upgrade_type" == "full" ] || [ "$upgrade_type" == "dep" ]; then - log_warn "Checking requirement of root user when installation is full or dep" - root_check - log_info "Successful checking of root user" - fi -} - -check_stage_requirements() { - log_section "Checking requirements for staged app preparation" - python_check - log_info "Valid version of Python" -} - -check_bootstrap_requirements() { - log_section "Checking requirements for application bootstrap" - python_check - log_info "Valid version of Python" - db_check - log_info "Successful check for database" -} - -# rename_apps_if_needed: handles legacy app renaming and DB/migration adjustments when --ren_app is provided. -rename_apps_if_needed() { - if [ $ren_app != true ]; then + ((SECONDS < deadline)) \ + || die "Unable to connect to database $DB_NAME at $DB_HOST:$DB_PORT after 60 seconds" + sleep 2 + done +} + +# ============================================================================ +# APPLICATION CUSTOMIZATION POINTS +# +# Keep generic lifecycle code outside this section. Each hook has a safe no-op +# default. Add project behavior here, document why it is required, and make it +# idempotent so a failed deployment can be retried safely. +# ============================================================================ + +install_application_system_packages() { + # iSkyLIMS owns these dependencies rather than its Dockerfile so the same + # lifecycle works in UBI containers and supported bare-metal deployments. + [[ "${SKIP_SYSTEM_PACKAGES:-0}" != "1" ]] || { + info "Skipping system package installation (SKIP_SYSTEM_PACKAGES=1)" return 0 - fi - - rm -rf $INSTALL_PATH/django_utils/migrations/* - rm -rf $INSTALL_PATH/iSkyLIMS_core/migrations/* - rm -rf $INSTALL_PATH/iSkyLIMS_wetlab/migrations/* - rm -rf $INSTALL_PATH/iSkyLIMS_drylab/migrations/* - - cd $INSTALL_PATH - sed -i "s/ugettext/gettext/g" iSkyLIMS_wetlab/models.py - sed -i "s/ugettext/gettext/g" iSkyLIMS_core/forms.py - sed -i "s/ugettext/gettext/g" django_utils/forms.py - echo "activate the virtualenv" - source virtualenv/bin/activate - - echo "Create a fake initial" - python manage.py makemigrations $FAKEINITIAL_MODULES - python manage.py migrate --fake-initial - - if [ -d "$INSTALL_PATH/iSkyLIMS_core" ]; then - echo "Changing app dir names in $INSTALL_PATH..." - rm -rf $INSTALL_PATH/.git $INSTALL_PATH/.github $INSTALL_PATH/.gitignore \ - $INSTALL_PATH/.Rhistory $INSTALL_PATH/docker-compose.test.yml $INSTALL_PATH/docker_iskylims_install.sh \ - $INSTALL_PATH/Dockerfile $INSTALL_PATH/install.sh $INSTALL_PATH/install_settings.txt - mv $INSTALL_PATH/iSkyLIMS_core $INSTALL_PATH/core - mv $INSTALL_PATH/iSkyLIMS_wetlab $INSTALL_PATH/wetlab - mv $INSTALL_PATH/iSkyLIMS_drylab $INSTALL_PATH/drylab - mv $INSTALL_PATH/iSkyLIMS_clinic $INSTALL_PATH/clinic - echo "Done changing app dir names in $INSTALL_PATH..." - fi - if [ -d "iSkyLIMS" ]; then - mv iSkyLIMS/ iskylims/ - sed -i "s/iSkyLIMS/iskylims/g" $INSTALL_PATH/iskylims/wsgi.py - sed -i "s/iSkyLIMS/iskylims/g" $INSTALL_PATH/manage.py - fi - - echo "Modifying database names and constraints..." - mysql -u $DB_USER -p$DB_PASS -D $DB_NAME -h $DB_SERVER_IP \ - -e 'UPDATE django_content_type SET app_label = REPLACE(app_label , "iSkyLIMS_core", "core") WHERE app_label like ("iSkyLIMS_%");' - mysql -u $DB_USER -p$DB_PASS -D $DB_NAME -h $DB_SERVER_IP \ - -e 'UPDATE django_content_type SET app_label = REPLACE(app_label , "iSkyLIMS_wetlab", "wetlab") WHERE app_label like ("iSkyLIMS_%");' - mysql -u $DB_USER -p$DB_PASS -D $DB_NAME -h $DB_SERVER_IP \ - -e 'UPDATE django_content_type SET app_label = REPLACE(app_label , "iSkyLIMS_drylab", "drylab") WHERE app_label like ("iSkyLIMS_%");' - - mysql -u $DB_USER -p$DB_PASS -D $DB_NAME -h $DB_SERVER_IP \ - -e 'UPDATE django_migrations SET app = REPLACE(app , "iSkyLIMS_core", "core") WHERE app like ("iSkyLIMS_%");' - mysql -u $DB_USER -p$DB_PASS -D $DB_NAME -h $DB_SERVER_IP \ - -e 'UPDATE django_migrations SET app = REPLACE(app , "iSkyLIMS_wetlab", "wetlab") WHERE app like ("iSkyLIMS_%");' - mysql -u $DB_USER -p$DB_PASS -D $DB_NAME -h $DB_SERVER_IP \ - -e 'UPDATE django_migrations SET app = REPLACE(app , "iSkyLIMS_drylab", "drylab") WHERE app like ("iSkyLIMS_%");' - - echo "Renaming tables" - query_rename_table="SELECT CONCAT('RENAME TABLE ', TABLE_SCHEMA, '.', TABLE_NAME, \ - ' TO ', TABLE_SCHEMA, '.', REPLACE(TABLE_NAME, 'iSkyLIMS_', ''), ';') \ - AS query FROM information_schema.tables WHERE TABLE_SCHEMA = \"$DB_NAME\" AND TABLE_NAME LIKE 'iSkyLIMS_%';" - mysql -u $DB_USER -p$DB_PASS -h $DB_SERVER_IP -e "$query_rename_table" \ - | xargs -I % echo "mysql -u$DB_USER -p'$DB_PASS' -D $DB_NAME -h $DB_SERVER_IP -e \"% \" " | bash - - echo "Renaming index" - query_rename_unique_indexes="SELECT CONCAT('ALTER TABLE ', rcu.TABLE_SCHEMA, '.', rcu.TABLE_NAME, \ - ' RENAME INDEX ', rcu.CONSTRAINT_NAME, \ - ' TO ', REPLACE(rcu.CONSTRAINT_NAME, 'iSkyLIMS_', ''), ';') \ - AS query FROM information_schema.key_column_usage rcu \ - JOIN information_schema.table_constraints tc \ - ON tc.CONSTRAINT_NAME = rcu.CONSTRAINT_NAME WHERE rcu.TABLE_SCHEMA = \"$DB_NAME\" \ - AND rcu.CONSTRAINT_NAME LIKE 'iSkyLIMS_%' AND tc.CONSTRAINT_TYPE = 'UNIQUE' \ - GROUP BY rcu.TABLE_SCHEMA, rcu.TABLE_NAME, rcu.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, \ - rcu.REFERENCED_TABLE_SCHEMA, rcu.REFERENCED_TABLE_NAME;" - mysql -u $DB_USER -p$DB_PASS -h $DB_SERVER_IP -e "$query_rename_unique_indexes" \ - | xargs -I % echo "mysql -u$DB_USER -p'$DB_PASS' -D $DB_NAME -h $DB_SERVER_IP -e \"% \" " | bash - - echo "Renaming constraints" - query_rename_constraints="SELECT CONCAT('ALTER TABLE ', rcu.TABLE_SCHEMA, '.', rcu.TABLE_NAME, \ - ' DROP FOREIGN KEY ' , rcu.CONSTRAINT_NAME, ';', \ - ' ALTER TABLE ', rcu.TABLE_SCHEMA, '.', rcu.TABLE_NAME, \ - ' ADD CONSTRAINT ', REPLACE(rcu.CONSTRAINT_NAME, 'iSkyLIMS_', ''), ' ', \ - tc.CONSTRAINT_TYPE, ' (', GROUP_CONCAT(rcu.COLUMN_NAME ORDER BY rcu.ORDINAL_POSITION SEPARATOR ', '), ')', \ - IF(tc.CONSTRAINT_TYPE = 'FOREIGN KEY', \ - CONCAT(' REFERENCES ', rcu.REFERENCED_TABLE_SCHEMA, '.', REPLACE(rcu.REFERENCED_TABLE_NAME, 'iSkyLIMS_', ''), ' (', \ - GROUP_CONCAT(rcu.REFERENCED_COLUMN_NAME ORDER BY rcu.ORDINAL_POSITION SEPARATOR ', '), ') ON DELETE ', rc.DELETE_RULE), \ - ''), ';') AS query \ - FROM information_schema.key_column_usage rcu \ - LEFT JOIN information_schema.table_constraints tc ON rcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME \ - LEFT JOIN information_schema.referential_constraints rc ON rcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME \ - WHERE rcu.TABLE_SCHEMA = '$DB_NAME' AND rcu.CONSTRAINT_NAME LIKE 'iSkyLIMS_%' \ - GROUP BY rcu.TABLE_SCHEMA, rcu.TABLE_NAME, rcu.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, rcu.REFERENCED_TABLE_SCHEMA, rcu.REFERENCED_TABLE_NAME, rc.DELETE_RULE;" - mysql -u $DB_USER -p$DB_PASS -h $DB_SERVER_IP -e "$query_rename_constraints" | xargs -I % echo "mysql -u$DB_USER -p'$DB_PASS' -D $DB_NAME -h $DB_SERVER_IP -e \"% \" " | bash - - echo "Done modifying database names and constraints..." - - echo "Modifying names in migration files..." - sed -i 's/iSkyLIMS_core/core/g' */migrations/*.py - sed -i 's/iSkyLIMS_drylab/drylab/g' */migrations/*.py - sed -i 's/iSkyLIMS_wetlab/wetlab/g' */migrations/*.py - echo "Done modifying names in migration files..." - - echo "Copying custom migration files from conf." - cp $INSTALL_PATH/conf/0002_core_migration_v3.0.0.py $INSTALL_PATH/core/migrations/0002_migration_v3_0_0.py - cp $INSTALL_PATH/conf/0002_drylab_migration_v3.0.0.py $INSTALL_PATH/drylab/migrations/0002_migration_v3_0_0.py - cp $INSTALL_PATH/conf/0002_wetlab_migration_v3.0.0.py $INSTALL_PATH/wetlab/migrations/0002_migration_v3_0_0.py - cp $INSTALL_PATH/conf/0002_django_utils_migration_v3.0.0.py $INSTALL_PATH/django_utils/migrations/0002_migration_v3_0_0.py - - read -p "Do you want to proceed with the migrate command? (Y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]] ; then - log "WARN" "Exiting without running migrate command." - exit 1 - fi - - echo "activate the virtualenv" - source virtualenv/bin/activate - echo "Running migrate..." - python manage.py migrate - echo "Done migrate command." - - cd - -} - -# install_system_packages: install InterOp and distro-specific OS packages required by iSkyLIMS. -install_system_packages() { - if [ "${SKIP_SYSTEM_PACKAGES:-}" = "1" ]; then - echo "Skipping system package installation (SKIP_SYSTEM_PACKAGES=1)" - return - fi - - echo "Installing Interop" - if [ -d /opt/interop ]; then - echo "There is already an interop installation" - echo "Skipping Interop installation" - else - cd /opt - echo "Downloading interop software" - wget https://github.com/Illumina/interop/releases/download/v1.1.15/InterOp-1.1.15-Linux-GNU.tar.gz - tar -xf InterOp-1.1.15-Linux-GNU.tar.gz - ln -s InterOp-1.1.15-Linux-GNU interop - rm InterOp-1.1.15-Linux-GNU.tar.gz - echo "Interop is now installed" - cd - - fi - - if command -v lsb_release >/dev/null 2>&1; then - linux_distribution=$(lsb_release -i | cut -f 2-) - else - linux_distribution=$(awk -F= '/^ID=/{gsub(/"/,""); print $2}' /etc/os-release) - fi - - if [[ $linux_distribution == "Ubuntu" || $linux_distribution == "ubuntu" ]]; then - echo "Software installation for Ubuntu" - apt-get update && apt-get upgrade -y - apt-get install -y \ - apt-utils wget \ - libmysqlclient-dev \ - python3-venv \ - libpq-dev \ - python3-dev python3-pip python3-wheel \ - apache2-dev cifs-utils \ - gnuplot - - elif [[ $linux_distribution == "CentOS" || $linux_distribution == "RedHatEnterprise" || $linux_distribution == "centos" || $linux_distribution == "rhel" || $linux_distribution == "fedora" ]]; then - echo "Software installation for Centos/RedHat" - yum groupinstall "Development tools" - yum install zlib-devel bzip2-devel openssl-devel \ - wget httpd-devel mysql-libs sqlite sqlite-devel \ - mariadb-devel libffi-devel \ - gnuplot cifs-utils - fi -} - -# run_django_deploy: execute makemigrations/migrate and optional fixture/superuser steps. -run_django_deploy() { - local mode="${1:-install}" - if [ "$run_script_before" = true ]; then - for val in "${migration_script_before[@]}"; do - if [[ $val = *","* ]]; then - parameters=(${val//,/ }) - echo "Running pre-migration script: ${parameters[0]}" - ./manage.py runscript ${parameters[0]} --script-args ${parameters[1]} - echo "Done pre-migration script: ${parameters[0]}" - else - echo "Running pre-migration script: $val" - ./manage.py runscript $val - echo "Done pre-migration script: $val" - fi - done - fi - - if [ "$mode" = "upgrade" ]; then - echo "Applying migrations in fake-initial mode" - python manage.py migrate --noinput --fake-initial - # Second pass ensures non-initial migrations are applied after fake-initial. - echo "Applying migrations" - python manage.py migrate --noinput - else - echo "Applying migrations" - python manage.py migrate --noinput - fi - - if [ "$tables" = true ]; then - echo "Loading pre-filled tables..." - load_tables "$prefilled_tables" true - echo "Done loading pre-filled tables..." - fi - - if [ "$run_script" = true ]; then - for val in "${migration_script[@]}"; do - if [[ $val = *","* ]]; then - parameters=(${val//,/ }) - echo "Running post-migration script: ${parameters[0]}" - ./manage.py runscript ${parameters[0]} --script-args ${parameters[1]} - echo "Done post-migration script: ${parameters[0]}" - else - echo "Running post-migration script: $val" - ./manage.py runscript $val - echo "Done post-migration script: $val" + } + [[ $(id -u) -eq 0 ]] || die "System dependency installation must run as root" + + local distribution="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + source /etc/os-release + distribution="${ID,,}" + fi + + case "$distribution" in + ubuntu|debian) + command_required apt-get + apt-get update + apt-get install -y --no-install-recommends \ + apt-utils wget tar gcc g++ make \ + libmysqlclient-dev default-mysql-client \ + python3-venv libpq-dev python3-dev python3-pip python3-wheel \ + apache2-dev cifs-utils gnuplot tzdata + ;; + rhel|centos|fedora|ubi) + command_required microdnf + command_required rpm + microdnf install -y wget tar + if ! rpm -q epel-release >/dev/null 2>&1; then + wget -O /tmp/epel-release.rpm \ + https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm + rpm -Uvh /tmp/epel-release.rpm + rm -f /tmp/epel-release.rpm fi - done - fi - - if [ "$mode" = "install" ]; then - echo "Creating super user " - python manage.py createsuperuser --username admin - fi -} + printf '%s\n' \ + '[centos-stream-baseos]' \ + 'name=CentOS Stream 9 - BaseOS' \ + 'baseurl=https://mirror.stream.centos.org/9-stream/BaseOS/$basearch/os/' \ + 'enabled=1' 'gpgcheck=0' '' \ + '[centos-stream-appstream]' \ + 'name=CentOS Stream 9 - AppStream' \ + 'baseurl=https://mirror.stream.centos.org/9-stream/AppStream/$basearch/os/' \ + 'enabled=1' 'gpgcheck=0' '' \ + '[centos-stream-crb]' \ + 'name=CentOS Stream 9 - CRB' \ + 'baseurl=https://mirror.stream.centos.org/9-stream/CRB/$basearch/os/' \ + 'enabled=1' 'gpgcheck=0' \ + > /etc/yum.repos.d/centos-stream-baseos.repo + microdnf install -y \ + gcc gcc-c++ make tar zlib-devel bzip2-devel openssl-devel wget \ + python3.11-devel httpd-devel sqlite sqlite-devel mariadb \ + mariadb-connector-c-devel libffi-devel gnuplot cifs-utils \ + git rsync shadow-utils + # UBI minimal can record tzdata as installed without its zoneinfo + # payload. Reinstall it so Python can resolve Django TIME_ZONE values. + microdnf reinstall -y tzdata + microdnf clean all + ;; + *) + die "Unsupported Linux distribution for dependency installation: ${distribution:-unknown}" + ;; + esac -refresh_static_files() { - echo "Deleting static files..." - if [ -d "$INSTALL_PATH/static" ]; then - if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$INSTALL_PATH/static"; then - echo "Static directory is a mount point. Skipping delete." + if [[ ! -e /opt/interop ]]; then + local archive="/tmp/InterOp-1.1.15-Linux-GNU.tar.gz" + wget -O "$archive" \ + https://github.com/Illumina/interop/releases/download/v1.1.15/InterOp-1.1.15-Linux-GNU.tar.gz + tar -xzf "$archive" -C /opt + ln -s /opt/InterOp-1.1.15-Linux-GNU /opt/interop + rm -f "$archive" + fi +} + +prepare_application_directories() { + # Argument: final INSTALL_PATH. Create application-specific persistent + # directories here. Generic logs/documents/static/cron/tmp already exist. + # Example: + # mkdir -p "$1/documents/genomic_files" "$1/logs/audit" + local install_path="$1" + if [[ "${LOG_TYPE:-regular_folder}" == "symbolic_link" ]]; then + [[ -n "${LOG_PATH:-}" ]] || die "LOG_PATH is required when LOG_TYPE=symbolic_link" + [[ -d "$LOG_PATH" ]] || die "Configured log directory does not exist: $LOG_PATH" + if [[ -L "$install_path/logs" ]]; then + [[ "$(readlink -f "$install_path/logs")" == "$(readlink -f "$LOG_PATH")" ]] || + die "$install_path/logs points to a different log directory" else - rm -rf "$INSTALL_PATH/static" || echo "Skipping static removal (busy)." + rmdir "$install_path/logs" 2>/dev/null || + die "$install_path/logs must be empty before it can become a symbolic link" + ln -s "$LOG_PATH" "$install_path/logs" fi fi - echo "Running collect statics..." - python manage.py collectstatic --noinput - echo "Done collect statics" -} - -# sync_requirements_file: copy repository requirements into the target installation path. -sync_requirements_file() { - mkdir -p $INSTALL_PATH/conf - rsync -rlv conf/requirements.txt $INSTALL_PATH/conf/requirements.txt -} - -# setup_virtualenv: create or refresh the Python virtualenv depending on mode. -setup_virtualenv() { - local mode="$1" - cd $INSTALL_PATH - if [ "$mode" = "install" ]; then - if [ -d virtualenv ]; then - echo "There already is a virtualenv for iskylims in $INSTALL_PATH." - read -p "Do you want to remove current virtualenv and reinstall? (Y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]] ; then - rm -rf $INSTALL_PATH/virtualenv - bash -c "$PYTHON_BIN_PATH -m venv virtualenv" - else - echo "virtualenv already defined. Skipping." - fi - else - bash -c "$PYTHON_BIN_PATH -m venv virtualenv" - fi - else - if [ -d virtualenv ]; then - read -p "Do you want to remove current virtualenv and reinstall? (Y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]] ; then - rm -rf $INSTALL_PATH/virtualenv - bash -c "$PYTHON_BIN_PATH -m venv virtualenv" - fi - else - read -p "There is no virtualenv. Do you want to create a new one? (Y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]] ; then - bash -c "$PYTHON_BIN_PATH -m venv virtualenv" - else - echo "Exiting..." - exit 0 - fi - fi - fi - cd - -} - -# prepare_documents_structure: ensure document directories and templates exist with correct permissions. -prepare_documents_structure() { - echo "Created documents structure" - mkdir -p $INSTALL_PATH/documents/wetlab - mkdir -p $INSTALL_PATH/documents/wetlab/tmp - mkdir -p $INSTALL_PATH/documents/wetlab/sample_sheet - mkdir -p $INSTALL_PATH/documents/wetlab/images_plot - mkdir -p $INSTALL_PATH/documents/wetlab/templates - mkdir -p $INSTALL_PATH/documents/wetlab/sample_sheets_lib_prep - mkdir -p $INSTALL_PATH/documents/drylab - mkdir -p $INSTALL_PATH/documents/drylab/service_files - - chown_if_root -R "$user:$apache_group" "$INSTALL_PATH/documents" - chmod 775 $INSTALL_PATH/documents - - cp $INSTALL_PATH/conf/*_template.csv $INSTALL_PATH/documents/wetlab/templates/ - cp $INSTALL_PATH/conf/samples_template.xlsx $INSTALL_PATH/documents/wetlab/templates/ - - mkdir -p $INSTALL_PATH/documents/wetlab/collection_index_kits/ - cp $INSTALL_PATH/conf/collection_index_kits/*.txt $INSTALL_PATH/documents/wetlab/collection_index_kits/ - - cp $INSTALL_PATH/conf/template_logging_config.ini $INSTALL_PATH/wetlab/logging_config.ini - sed -i "s|INSTALL_PATH|${INSTALL_PATH}|g" $INSTALL_PATH/wetlab/logging_config.ini -} - -# install_python_requirements: activate the venv and install required Python packages. -install_python_requirements() { - cd $INSTALL_PATH - echo "activate the virtualenv" - source virtualenv/bin/activate - echo "Installing required python packages" - python -m pip install --upgrade pip - python -m pip install wheel + mkdir -p \ + "$install_path/documents/wetlab/tmp" \ + "$install_path/documents/wetlab/sample_sheet" \ + "$install_path/documents/wetlab/images_plot" \ + "$install_path/documents/wetlab/templates" \ + "$install_path/documents/wetlab/sample_sheets_lib_prep" \ + "$install_path/documents/wetlab/collection_index_kits" \ + "$install_path/documents/drylab/service_files" +} + +stage_application_custom_files() { + # Arguments: source directory, final INSTALL_PATH, action (install|upgrade). + # Copy application-owned files that intentionally need extra processing; + # the standard already installs the Django URL and optional routing files. + local source_dir="$1" install_path="$2" + local template + + for template in "$source_dir"/conf/*_template.csv "$source_dir"/conf/samples_template.xlsx; do + [[ -e "$template" ]] || continue + install -m 0644 "$template" "$install_path/documents/wetlab/templates/" + done + for template in "$source_dir"/conf/collection_index_kits/*.txt; do + [[ -e "$template" ]] || continue + install -m 0644 "$template" "$install_path/documents/wetlab/collection_index_kits/" + done + if [[ -f "$source_dir/conf/template_logging_config.ini" ]]; then + sed "s|INSTALL_PATH|$install_path|g" "$source_dir/conf/template_logging_config.ini" \ + > "$install_path/wetlab/logging_config.ini" + fi +} + +write_application_runtime_env() { + # Argument: final INSTALL_PATH. Use this only when the application reads a + # runtime .env in addition to Django settings. Never hard-code credentials. + # Patho Core-style example: + # umask 077 + # printf 'OIDC_ISSUER=%s\n' "${OIDC_ISSUER:?required}" > "$1/.env" + # for key in $(compgen -A variable KEYCLOAK_ | sort); do + # printf '%s=%s\n' "$key" "${!key}" >> "$1/.env" + # done + : +} + +validate_application_runtime() { + # Called after DB connectivity and before migrations. Validate optional + # identity-provider or feature configuration here. + # Example: require KEYCLOAK_ISSUER only when legacy auth is disabled: + # [[ "${ENABLE_LEGACY_AUTH:-true}" == true || -n "${KEYCLOAK_ISSUER:-}" ]] || + # die "KEYCLOAK_ISSUER is required when legacy auth is disabled" + : +} + +before_django_migrate() { + # Arguments: action and space-separated MIGRATION_MODULES. This is for + # application migration preparation, not the user-selected runscript hooks. + # iSkyLIMS commits its migrations. Generating migrations during deployment + # would make container images and upgrades non-reproducible. + local action="$1" migration_modules="$2" module + + echo "Validating Django migration plan for $action" + for module in $migration_modules; do + python manage.py showmigrations "$module" --plan >/dev/null \ + || die "Unable to inspect migrations for Django app: $module" + done + python manage.py migrate --plan --noinput >/dev/null \ + || die "Unable to calculate the Django migration plan" +} + +after_django_migrate() { + # The initial administrator belongs only to a fresh runtime bootstrap. A + # repeated bootstrap leaves an existing account and password unchanged. + [[ "$WORKFLOW" == "bootstrap" && "$ACTION" == "install" ]] || return 0 + [[ "${CREATE_INITIAL_SUPERUSER:-false}" == "true" ]] || return 0 + : "${DJANGO_SUPERUSER_USERNAME:?DJANGO_SUPERUSER_USERNAME is required}" + : "${DJANGO_SUPERUSER_PASSWORD:?DJANGO_SUPERUSER_PASSWORD is required}" + + DJANGO_SUPERUSER_USERNAME="$DJANGO_SUPERUSER_USERNAME" \ + DJANGO_SUPERUSER_EMAIL="${DJANGO_SUPERUSER_EMAIL:-}" \ + DJANGO_SUPERUSER_PASSWORD="$DJANGO_SUPERUSER_PASSWORD" \ + python manage.py shell <<'PY' +import os + +from django.contrib.auth import get_user_model + +user_model = get_user_model() +username = os.environ["DJANGO_SUPERUSER_USERNAME"] +email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "") +password = os.environ["DJANGO_SUPERUSER_PASSWORD"] +lookup = {user_model.USERNAME_FIELD: username} +user, created = user_model._default_manager.get_or_create(**lookup) +if created: + if hasattr(user, "email"): + user.email = email + user.is_staff = True + user.is_superuser = True + user.set_password(password) + user.save() + print(f"Created initial superuser: {username}") +else: + print(f"Initial superuser already exists: {username}") +PY +} + +set_application_permissions() { + # Argument: final INSTALL_PATH. Direct/bare-metal installs can customize + # owner/group here; container orchestration owns container mount permissions. + # Example: chown -R "${APP_UID}:${APP_GID}" "$1/logs" "$1/documents" + local install_path="$1" + if [[ "$WORKFLOW" == "standard" && $(id -u) -eq 0 ]]; then + local owner="${SUDO_USER:-root}" apache_group="apache" + [[ -f /etc/debian_version ]] && apache_group="www-data" + getent group "$apache_group" >/dev/null 2>&1 || + die "Apache runtime group does not exist: $apache_group" + chown -R "$owner:$apache_group" \ + "$install_path/logs" "$install_path/documents" "$install_path/static" + fi + chmod -R u+rwX,g+rwX "$install_path/logs" "$install_path/documents" "$install_path/static" +} + +restart_application_server() { + # Called only for a direct standard workflow unless restart was skipped. + # Example: systemctl reload apache2 (or httpd on RHEL-family systems). + local service="httpd" + [[ -f /etc/debian_version ]] && service="apache2" + command -v systemctl >/dev/null 2>&1 || return 0 + systemctl restart "$service" +} + +# ========================= END APPLICATION CUSTOMIZATION ===================== + +stage_dependencies() { + checkout_git_revision + check_python + check_required_modules + install_application_system_packages + mkdir -p "$INSTALL_PATH" + [[ -d "$INSTALL_PATH/virtualenv" ]] \ + || "$PYTHON_BIN_PATH" -m venv "$INSTALL_PATH/virtualenv" + # shellcheck disable=SC1091 + source "$INSTALL_PATH/virtualenv/bin/activate" + python -m pip install --upgrade pip wheel + [[ -f conf/requirements.txt ]] || die "Missing conf/requirements.txt" python -m pip install -r conf/requirements.txt - cd - -} - -ensure_virtualenv_ready() { - if [ ! -d "$INSTALL_PATH/virtualenv" ]; then - log_warn "Virtualenv missing. INSTALL_PATH=$INSTALL_PATH" - ls -la "$INSTALL_PATH" || true - abort_install "Virtualenv not found at $INSTALL_PATH/virtualenv. Run --install dep first." - fi -} - -# restart_apache_service: restart Apache/HTTPD unless running inside Docker or explicitly skipped. -restart_apache_service() { - if command -v lsb_release >/dev/null 2>&1; then - linux_distribution=$(lsb_release -i | cut -f 2-) - else - linux_distribution=$(awk -F= '/^ID=/{gsub(/"/,""); print $2}' /etc/os-release) - fi - if [[ $linux_distribution == "Ubuntu" ]]; then - apache_daemon="apache2" - else - apache_daemon="httpd" - fi - if ! systemctl restart $apache_daemon; then - echo -e "${ORANGE}Apache server restart failed. trying with sudo${NC}" - sudo systemctl restart $apache_daemon - fi } -# run_dependency_stage: execute the dependency portion (system packages + venv + pip) for install or upgrade. -run_dependency_stage() { - local mode="$1" - - if [ "$mode" = "install" ]; then - log_section "Preparing dependency environment for installation" - if [ -d $INSTALL_PATH ]; then - echo "There already is an installation of iskylims in $INSTALL_PATH." - read -p "Do you want to remove current installation and reinstall? (Y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]] ; then - echo "Exiting without running iSkyLIMS installation" - exit 1 - else - rm -rf $INSTALL_PATH - fi - fi - install_system_packages - mkdir -p $INSTALL_PATH - linux_distribution=$(lsb_release -i | cut -f 2-) - if [[ $linux_distribution == "Ubuntu" ]]; then - apache_group="www-data" - else - apache_group="apache" - fi - chown_if_root -R "$user:$apache_group" "$INSTALL_PATH" - chmod 775 $INSTALL_PATH - else - log_section "Preparing dependency environment for upgrade" - if [ ! -d $INSTALL_PATH ]; then - abort_install "Unable to start the upgrade. Folder $INSTALL_PATH does not exist." - fi - install_system_packages - fi - - sync_requirements_file - setup_virtualenv "$mode" - install_python_requirements -} - -# upgrade_application_files: sync code/config and run upgrade-specific tasks (renames, migrations). -upgrade_application_files() { - if [ ! -d $INSTALL_PATH ]; then - abort_install "Unable to start the upgrade. Folder $INSTALL_PATH does not exist." - fi - - log_section "Starting iSkyLIMS Upgrade version: ${APP_VERSION}" - - rename_apps_if_needed - - stage_upgrade_application_files - bootstrap_application_runtime "upgrade" - - log_section "Successfuly upgrade of iSKyLIMS version: ${APP_VERSION}" -} - -# stage_upgrade_application_files: sync code/config into INSTALL_PATH without DB work. -stage_upgrade_application_files() { - if [ ! -d $INSTALL_PATH ]; then - abort_install "Unable to start the upgrade. Folder $INSTALL_PATH does not exist." - fi - - echo "Copying files to installation folder" - rsync -rlv conf/ $INSTALL_PATH/conf/ - rsync -rlv --fuzzy --delay-updates --delete-delay \ - --exclude "logs" --exclude "documents" --exclude "__pycache__" \ - README.md LICENSE test conf $REQUIRED_MODULES $INSTALL_PATH - - cd $INSTALL_PATH - ensure_virtualenv_ready - echo "activate the virtualenv" +stage_application_files() { + checkout_git_revision + [[ -d "$INSTALL_PATH/virtualenv" ]] \ + || die "virtualenv not found at $INSTALL_PATH; install dependencies first" + # The Django wrapper is deployment-generated and must never be inherited + # from an ignored local source tree or a previous staged installation. + rm -rf "$INSTALL_PATH/$PROJECT_MODULE" + rm -f "$INSTALL_PATH/manage.py" + rsync -rl --delete \ + --exclude .git --exclude .env --exclude /logs --exclude /documents \ + --exclude /static --exclude /cron --exclude /tmp --exclude /virtualenv \ + --exclude /manage.py --exclude "/$PROJECT_MODULE" \ + ./ "$INSTALL_PATH/" + mkdir -p "$INSTALL_PATH/logs" "$INSTALL_PATH/documents" \ + "$INSTALL_PATH/static" "$INSTALL_PATH/cron" "$INSTALL_PATH/tmp" + prepare_application_directories "$INSTALL_PATH" + # Run from the clean staged tree so a source directory such as conf/ cannot + # be mistaken for an importable module that conflicts with PROJECT_MODULE. + ( + cd "$INSTALL_PATH" + PYTHONPATH= "$INSTALL_PATH/virtualenv/bin/python" -m django startproject \ + "$PROJECT_MODULE" . + ) + install -m 0644 "$install_script_dir/conf/urls.py" \ + "$INSTALL_PATH/$PROJECT_MODULE/urls.py" + if [[ -f "$install_script_dir/conf/routing.py" ]]; then + install -m 0644 "$install_script_dir/conf/routing.py" \ + "$INSTALL_PATH/$PROJECT_MODULE/routing.py" + fi + stage_application_custom_files "$install_script_dir" "$INSTALL_PATH" "$ACTION" + printf '%s\n' "$GIT_REVISION" > "$INSTALL_PATH/.deployed_revision" + if [[ "$RENDER_SETTINGS" == "true" ]]; then + local template="$install_script_dir/conf/template_settings.py" + local output="${SETTINGS_OUTPUT:-$INSTALL_PATH/$PROJECT_MODULE/settings.py}" + [[ -f "$template" ]] || die "Django settings template not found: $template" + render_django_settings_file "$template" "$output" "$INSTALL_CONF" + fi + write_application_runtime_env "$INSTALL_PATH" + set_application_permissions "$INSTALL_PATH" +} + +run_hook() { + local specification="$1" script_name="${1%%,*}" + local -a args=(manage.py runscript "$script_name") + [[ -n "$script_name" ]] || die "Empty migration script name" + [[ "$specification" != *,* ]] || args+=(--script-args "${specification#*,}") + python "${args[@]}" +} + +check_for_missing_migrations() { + # Deployment must never invent schema history. Fail when model changes need + # migration files that have not been generated and committed by developers. + python manage.py makemigrations --check --dry-run --noinput \ + || die "Model changes detected without committed Django migrations" +} + +bootstrap_application() { + [[ -f "$INSTALL_PATH/manage.py" ]] || die "manage.py not found; run --stage first" + [[ -x "$INSTALL_PATH/virtualenv/bin/python" ]] || die "virtualenv not found; run --stage first" + cd "$INSTALL_PATH" + # shellcheck disable=SC1091 source virtualenv/bin/activate - - if [ ! -f "$INSTALL_PATH/manage.py" ]; then - echo "manage.py not found. Creating ${PROJECT_NAME} project" - "$INSTALL_PATH/virtualenv/bin/python" -m django startproject "$PROJECT_NAME" . + check_database + validate_application_runtime + python manage.py check --deploy + local hook + for hook in "${SCRIPT_BEFORE[@]}"; do run_hook "$hook"; done + check_for_missing_migrations + before_django_migrate "$ACTION" "$MIGRATION_MODULES" + python manage.py migrate --noinput + if [[ "$LOAD_TABLES" == "true" && "$SKIP_TABLES" == "false" ]]; then + [[ -f conf/first_install_tables.json ]] \ + || die "Initial table fixture not found: conf/first_install_tables.json" + python manage.py loaddata conf/first_install_tables.json + fi + for hook in "${SCRIPT_AFTER[@]}"; do run_hook "$hook"; done + after_django_migrate "$ACTION" + python manage.py collectstatic --noinput + local migration_log + migration_log="$(mktemp "${TMPDIR:-/tmp}/iskylims-migrations.XXXXXX.log")" + if ! python manage.py showmigrations --plan > "$migration_log" 2>&1 \ + || grep -Fq '[ ]' "$migration_log"; then + cat "$migration_log" >&2 + rm -f "$migration_log" + die "Django migration verification failed" fi - - echo "Update settings and url file." - update_settings_and_urls - prepare_documents_structure - - cd - -} - -# install_application_files: deploy Django project files, update settings, and run initial migrations. -install_application_files() { - stage_install_application_files - bootstrap_application_runtime "install" - log_section "Successfuly iSkyLIMS Installation version: ${APP_VERSION}" - echo "Installation completed" + rm -f "$migration_log" } -# stage_install_application_files: copy app files into INSTALL_PATH without DB work. -stage_install_application_files() { - log_section "Starting iSkyLIMS install version: ${APP_VERSION}" - - user=${SUDO_USER:-$USER} - group=$(groups | cut -d" " -f1) - - if command -v lsb_release >/dev/null 2>&1; then - linux_distribution=$(lsb_release -i | cut -f 2-) - else - linux_distribution=$(awk -F= '/^ID=/{gsub(/"/,""); print $2}' /etc/os-release) - fi - - if [[ $linux_distribution == "Ubuntu" || $linux_distribution == "ubuntu" ]]; then - apache_group="www-data" - else - apache_group="apache" - fi - - if [ "$install_type" == "full" ] || [ "$install_type" == "app" ]; then +remember_git_ref +trap restore_git_ref EXIT - if [ $LOG_TYPE == "symbolic_link" ]; then - if [ -d $LOG_PATH ]; then - if [ -e "$INSTALL_PATH/logs" ]; then - echo "Log target $INSTALL_PATH/logs already exists. Leaving it unchanged." - else - echo "Creating symbolic link to log folder" - ln -s "$LOG_PATH" "$INSTALL_PATH/logs" - chmod 775 "$LOG_PATH" - fi - else - echo "Log folder path: $LOG_PATH does not exist. Fix it in the install_settings.txt and run again." - exit 1 - fi - else - if [ ! -d $INSTALL_PATH/logs ]; then - mkdir -p $INSTALL_PATH/logs - chown_if_root "$user:$apache_group" "$INSTALL_PATH/logs" - chmod 775 $INSTALL_PATH/logs - else - echo "Log folder path: $INSTALL_PATH/logs already exist." - fi +case "$WORKFLOW" in + stage) stage_dependencies; stage_application_files ;; + bootstrap) bootstrap_application ;; + standard) + if [[ "$OPERATION_SCOPE" == "full" || "$OPERATION_SCOPE" == "dep" ]]; then + stage_dependencies fi + if [[ "$OPERATION_SCOPE" == "full" || "$OPERATION_SCOPE" == "app" ]]; then + stage_application_files + bootstrap_application + fi + if [[ "$SKIP_APACHE_RESTART" == "false" ]]; then restart_application_server; fi + ;; + *) die "Invalid workflow: $WORKFLOW" ;; +esac - rsync -rlv README.md LICENSE test conf $REQUIRED_MODULES $INSTALL_PATH - - cd $INSTALL_PATH - - prepare_documents_structure - - ensure_virtualenv_ready - echo "activate the virtualenv" - source virtualenv/bin/activate - - echo "Creating ${PROJECT_NAME} project" - "$INSTALL_PATH/virtualenv/bin/python" -m django startproject "$PROJECT_NAME" . - - update_settings_and_urls - - cd - - fi -} - -# bootstrap_application_runtime: run DB/bootstrap tasks against an already staged INSTALL_PATH. -bootstrap_application_runtime() { - local mode="$1" - - if [ ! -d "$INSTALL_PATH" ]; then - abort_install "Unable to bootstrap application. Folder $INSTALL_PATH does not exist." - fi - - cd $INSTALL_PATH - ensure_virtualenv_ready - echo "activate the virtualenv" - source virtualenv/bin/activate - - if [ ! -f "$INSTALL_PATH/manage.py" ]; then - abort_install "manage.py not found at $INSTALL_PATH/manage.py. Stage application files first." - fi - - update_settings_and_urls - run_django_deploy "$mode" - refresh_static_files - - cd - -} - -# translate long options to short -reset=true -for arg in "$@" -do - if [ -n "$reset" ]; then - unset reset - set -- # this resets the "$@" array so we can rebuild it - fi - case "$arg" in - # OPTIONAL - --install) set -- "$@" -i ;; - --upgrade) set -- "$@" -u ;; - --stage) set -- "$@" -j ;; - --bootstrap) set -- "$@" -l ;; - --script) set -- "$@" -s ;; - --script_before) set -- "$@" -p ;; - --script_after) set -- "$@" -o ;; - --script_prev) set -- "$@" -p ;; - --tables) set -- "$@" -t ;; - --skip_tables) set -- "$@" -b ;; - --git_revision) set -- "$@" -g ;; - --conf) set -- "$@" -c ;; - --ren_app) set -- "$@" -r ;; - --docker) set -- "$@" -k ;; - --skip_apache_restart) set -- "$@" -a ;; - - # ADITIONAL - --help) set -- "$@" -h ;; - --version) set -- "$@" -v ;; - # PASSING VALUE IN PARAMETER - *) set -- "$@" "$arg" ;; - esac -done - -# SETTING DEFAULT VALUES -ren_app=false -tables=false -git_branch=$initial_git_ref -conf="./install_settings.txt" -install=true -install_type="full" -upgrade=false -upgrade_type="full" -workflow="standard" -workflow_mode="" -docker=false -prefilled_tables="conf/first_install_tables.json" -restart_apache=true -run_script=false -run_script_before=false -migration_script=() -migration_script_before=() -skip_tables=false - -# PARSE VARIABLE ARGUMENTS WITH getops -options=":c:s:i:u:j:l:r:g:tdbkvhao:p:" -while getopts $options opt; do - case $opt in - i ) - workflow="standard" - install=true - upgrade=false - if [[ "$OPTARG" == "full" || "$OPTARG" == "dep" || "$OPTARG" == "app" ]]; then - install_type=$OPTARG - upgrade_type=$OPTARG - else - echo "Install is not set to one valid option. Use: --install full/app/dep" - exit 1 - fi - ;; - u ) - workflow="standard" - install=false - upgrade=true - if [[ "$OPTARG" == "full" || "$OPTARG" == "dep" || "$OPTARG" == "app" ]]; then - upgrade_type=$OPTARG - install_type=$OPTARG - else - echo "Upgrade is not set to one valid option. Use: --upgrade full/app/dep" - exit 1 - fi - ;; - j ) - workflow="stage" - workflow_mode=$OPTARG - if [[ "$workflow_mode" != "install" && "$workflow_mode" != "upgrade" ]]; then - echo "Stage is not set to one valid option. Use: --stage install/upgrade" - exit 1 - fi - ;; - l ) - workflow="bootstrap" - workflow_mode=$OPTARG - if [[ "$workflow_mode" != "install" && "$workflow_mode" != "upgrade" ]]; then - echo "Bootstrap is not set to one valid option. Use: --bootstrap install/upgrade" - exit 1 - fi - ;; - s ) - run_script=true - migration_script+=("$OPTARG") - ;; - p ) - run_script_before=true - migration_script_before+=("$OPTARG") - ;; - o ) - run_script=true - migration_script+=("$OPTARG") - ;; - t ) - tables=true - ;; - b ) - tables=false - skip_tables=true - ;; - r ) - ren_app=true - ;; - g ) - git_branch=$OPTARG - ;; - c ) - conf=$OPTARG - ;; - k ) - docker=true - restart_apache=false - ;; - a ) - restart_apache=false - ;; - h ) - usage - exit 1 - ;; - v ) - echo $APP_VERSION - exit 1 - ;; - \?) - echo "Invalid Option: -$OPTARG" 1>&2 - usage - exit 1 - ;; - : ) - echo "Option -$OPTARG requires an argument." >&2 - exit 1 - ;; - * ) - echo "Unimplemented option: -$OPTARG" >&2; - exit 1 - ;; - esac -done -shift $((OPTIND-1)) - -operation="install" -operation_scope="$install_type" -if [ $upgrade == true ]; then - operation="upgrade" - operation_scope="$upgrade_type" -fi -if [ "$workflow" != "standard" ]; then - operation="$workflow_mode" - operation_scope="app" -fi - -# Default to loading initial tables on installs unless explicitly skipped. -if [ "$operation" = "install" ] && [ "$skip_tables" = false ] && [ "$tables" = false ]; then - tables=true -fi - -load_install_config -PROJECT_NAME="${PROJECT_NAME:-iskylims}" -checkout_git_revision -user=${SUDO_USER:-$USER} - -if [ "$workflow" = "stage" ]; then - check_stage_requirements - if [ "$workflow_mode" = "install" ]; then - stage_install_application_files - else - stage_upgrade_application_files - fi - exit 0 -fi - -if [ "$workflow" = "bootstrap" ]; then - check_bootstrap_requirements - if [ "$workflow_mode" = "upgrade" ]; then - rename_apps_if_needed - fi - bootstrap_application_runtime "$workflow_mode" - exit 0 -fi - -check_requirements - -if [[ "$operation_scope" == "full" || "$operation_scope" == "dep" ]]; then - run_dependency_stage "$operation" - if [ "$operation_scope" = "dep" ]; then - log_info "Dependency stage completed." - exit 0 - fi -fi - -if [[ "$operation_scope" == "full" || "$operation_scope" == "app" ]]; then - if [ "$operation" = "install" ]; then - install_application_files - else - upgrade_application_files - fi - if [ $restart_apache == true ]; then - restart_apache_service - fi - exit 0 -fi - -printf "\n\n%s" -printf "${RED}------------------${NC}\n" -printf "%s" -printf "${RED}Invalid installation parameters${NC}\n" -printf "%s" -printf "${RED}------------------${NC}\n\n" -echo "See the usage examples" -usage -exit 1 +info "$WORKFLOW $ACTION completed for iSkyLIMS at $INSTALL_PATH" diff --git a/scripts/container_start.sh b/scripts/container_start.sh old mode 100644 new mode 100755 index 5f8a94194..ac648ec5f --- a/scripts/container_start.sh +++ b/scripts/container_start.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -APP_DIR="${INSTALL_PATH:-/opt/iskylims}" +# Shared RELECOV Platform/iSkyLIMS runtime pattern, parameterized per project. +APP_DIR="${APP_INSTALL_PATH:-${INSTALL_PATH:-/opt/iskylims}}" CRON_DIR="${APP_DIR}/cron" TMP_DIR="${APP_DIR}/tmp" CRON_FILE="${CRON_DIR}/iskylims" @@ -10,24 +11,35 @@ CRON_DISABLED_FILE="${CRON_DIR}/disabled" APP_MODE="${APP_MODE:-prod}" APP_PORT="${APP_PORT:-8001}" PROJECT_MODULE="${PROJECT_MODULE:-iskylims}" -GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-120}" +DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-${PROJECT_MODULE}.settings}" +GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-300}" GUNICORN_KEEPALIVE="${GUNICORN_KEEPALIVE:-5}" GUNICORN_THREADS="${GUNICORN_THREADS:-2}" +WAIT_TIMEOUT_SECONDS="${APP_START_WAIT_TIMEOUT_SECONDS:-100}" + +export APP_INSTALL_PATH="$APP_DIR" +export INSTALL_PATH="$APP_DIR" +export PROJECT_MODULE DJANGO_SETTINGS_MODULE + +wait_for_file() { + local path="$1" + local description="$2" + local wait_start="$SECONDS" + while [[ ! -f "$path" ]]; do + if ((SECONDS - wait_start >= WAIT_TIMEOUT_SECONDS)); then + echo "Timed out after ${WAIT_TIMEOUT_SECONDS}s waiting for ${description}: ${path}" >&2 + ls -la "$(dirname "$path")" >&2 || true + exit 1 + fi + sleep 2 + done +} -if [ ! -f "${APP_DIR}/manage.py" ]; then - echo "Application entrypoint not found at ${APP_DIR}/manage.py" >&2 - ls -la "${APP_DIR}" >&2 || true - exit 1 -fi - -if [ ! -f "${APP_DIR}/virtualenv/bin/activate" ]; then - echo "Virtualenv activation script not found at ${APP_DIR}/virtualenv/bin/activate" >&2 - ls -la "${APP_DIR}/virtualenv" >&2 || true - exit 1 -fi +wait_for_file "${APP_DIR}/manage.py" "Django application entrypoint" +wait_for_file "${APP_DIR}/virtualenv/bin/activate" "virtualenv activation script" +# shellcheck disable=SC1091 source "${APP_DIR}/virtualenv/bin/activate" - mkdir -p "${CRON_DIR}" "${TMP_DIR}" safe_chmod() { @@ -35,11 +47,9 @@ safe_chmod() { shift local path for path in "$@"; do - if [ ! -e "${path}" ]; then - continue - fi - if [ -O "${path}" ]; then - chmod "${mode}" "${path}" + [[ -e "$path" ]] || continue + if [[ -O "$path" ]]; then + chmod "$mode" "$path" else echo "Skipping chmod ${mode} on ${path}: not owned by $(id -un)." fi @@ -48,82 +58,78 @@ safe_chmod() { safe_chmod 700 "${CRON_DIR}" "${TMP_DIR}" -if [ "$APP_MODE" = "dev" ]; then - exec python "${APP_DIR}/manage.py" runserver "0.0.0.0:${APP_PORT}" -fi - -if [ -f "${CRON_DISABLED_FILE}" ]; then +if [[ -f "$CRON_DISABLED_FILE" ]]; then echo "Cron is disabled by ${CRON_DISABLED_FILE}. Skipping supercronic start." elif command -v supercronic >/dev/null 2>&1; then - # Build supercronic's crontab directly from Django settings. Avoid the - # system crontab command because it depends on PAM behavior that varies - # across rootless container hosts. python - <<'PY' > "${CRON_FILE}" import os import shlex +import sys -os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", - os.environ.get("DJANGO_SETTINGS_MODULE", "iskylims.settings"), -) +app_dir = os.environ["APP_INSTALL_PATH"] +settings_module = os.environ["DJANGO_SETTINGS_MODULE"] +sys.path.insert(0, app_dir) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module) import django django.setup() from django.conf import settings -app_dir = os.environ.get("INSTALL_PATH", "/opt/iskylims") python_bin = os.path.join(app_dir, "virtualenv", "bin", "python") -settings_module = os.environ.get("DJANGO_SETTINGS_MODULE", "iskylims.settings") command_suffix = getattr(settings, "CRONTAB_COMMAND_SUFFIX", "") for job in getattr(settings, "CRONJOBS", []): if len(job) < 2: continue - - schedule = job[0] - dotted_path = job[1] + schedule, dotted_path = job[:2] job_suffix = job[2] if len(job) > 2 else "" module_name, function_name = dotted_path.rsplit(".", 1) python_code = ( "import os; " f"os.environ.setdefault('DJANGO_SETTINGS_MODULE', {settings_module!r}); " "import django; django.setup(); " - f"from {module_name} import {function_name} as cron_job; " - "cron_job()" + f"from {module_name} import {function_name} as cron_job; cron_job()" ) command = ( f"cd {shlex.quote(app_dir)} && " f"DJANGO_SETTINGS_MODULE={shlex.quote(settings_module)} " f"{shlex.quote(python_bin)} -c {shlex.quote(python_code)}" ) - suffixes = " ".join(s for s in (job_suffix, command_suffix) if s) + suffixes = " ".join(value for value in (job_suffix, command_suffix) if value) if suffixes: command = f"{command} {suffixes}" - print(f"{schedule} {command}") PY - if [ -s "${CRON_FILE}" ]; then - safe_chmod 600 "${CRON_FILE}" - : > "${CRON_LOG}" - supercronic "${CRON_FILE}" > "${CRON_LOG}" 2>&1 & + if [[ -s "$CRON_FILE" ]]; then + safe_chmod 600 "$CRON_FILE" + : > "$CRON_LOG" + supercronic "$CRON_FILE" > "$CRON_LOG" 2>&1 & CRON_PID=$! sleep 1 - if ! kill -0 "${CRON_PID}" 2>/dev/null; then - echo "supercronic failed to start. Check ${CRON_LOG} for details." + if ! kill -0 "$CRON_PID" 2>/dev/null; then + echo "supercronic failed to start. Check ${CRON_LOG}." >&2 + exit 1 fi else - echo "No cron entries found. Skipping crond start." + echo "No Django CRONJOBS found. Skipping supercronic start." fi else - echo "supercronic not found. Skipping cron." + echo "supercronic not found. Scheduled jobs are disabled." +fi + +if [[ "$APP_MODE" == "dev" ]]; then + # Disable Django's reloader so it cannot duplicate the cron worker that + # was started above. Test settings enable Django's static/media serving. + exec python "${APP_DIR}/manage.py" runserver \ + --noreload "0.0.0.0:${APP_PORT}" fi -if [ -n "${WEB_CONCURRENCY:-}" ]; then - GUNICORN_WORKERS="${WEB_CONCURRENCY}" +if [[ -n "${WEB_CONCURRENCY:-}" ]]; then + GUNICORN_WORKERS="$WEB_CONCURRENCY" else cpu_count="$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 1)" - if [ "${cpu_count}" -le 2 ]; then + if ((cpu_count <= 2)); then GUNICORN_WORKERS=2 else GUNICORN_WORKERS=4 @@ -132,8 +138,10 @@ fi exec gunicorn "${PROJECT_MODULE}.wsgi:application" \ --bind "0.0.0.0:${APP_PORT}" \ - --workers "${GUNICORN_WORKERS}" \ - --threads "${GUNICORN_THREADS}" \ - --keep-alive "${GUNICORN_KEEPALIVE}" \ - --timeout "${GUNICORN_TIMEOUT}" \ - --worker-tmp-dir /dev/shm + --workers "$GUNICORN_WORKERS" \ + --threads "$GUNICORN_THREADS" \ + --keep-alive "$GUNICORN_KEEPALIVE" \ + --timeout "$GUNICORN_TIMEOUT" \ + --worker-tmp-dir /dev/shm \ + --access-logfile - \ + --error-logfile - diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh new file mode 100755 index 000000000..db01c0992 --- /dev/null +++ b/scripts/smoke_test.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +# Reuse exactly the same engine and Compose frontend selection as the outer +# installer. In particular, Podman prefers podman-compose when it is installed +# instead of delegating `podman compose` to an unrelated Docker Compose plugin. +# shellcheck disable=SC1091 +source "$repo_root/deployment/lib/container/common.sh" + +install_services=(iskylims) +engine="docker"; mode="production"; compose_file=""; env_file="" +while (($#)); do + case "$1" in + --test) mode="test"; shift ;; + --engine) engine="${2:-}"; shift 2 ;; + --compose_file) compose_file="${2:-}"; shift 2 ;; + --env_file) env_file="${2:-}"; shift 2 ;; + --help) echo "Usage: $0 [--test] [--engine docker|podman]"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done +compose_file="${compose_file:-docker-compose.$([ "$mode" = test ] && echo test || echo prod).yml}" +compose_env_file="$env_file" +set_engine +# The generated dotenv file is mode 0600 and contains shell-safe quoted values. +# Source it so direct host checks use the same service ports as Compose. +if [ -n "$env_file" ]; then + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a +fi +compose_run() { compose_with_env_exec -f "$compose_file" "$@"; } +fail() { echo "FAIL: $*" >&2; exit 1; } +compose_run config >/dev/null + container_id="$(resolve_service_container iskylims)" + [ -n "$container_id" ] || fail "Service iskylims has no container" + ensure_service_running iskylims "$container_id" >/dev/null + engine_exec exec "$container_id" bash -lc 'cd "$INSTALL_PATH" && source virtualenv/bin/activate && python manage.py check && ! python manage.py showmigrations --plan | grep -F '"'"'[ ]'"'"'' + echo "PASS: iskylims Django checks and migrations" +check_url() { + local service="$1" url="$2" + curl --fail --silent --show-error --location --max-time 20 --output /dev/null "$url" \ + || { echo "FAIL: $service health endpoint: $url" >&2; return 1; } + echo "PASS: $service health endpoint" +} +for service in "${install_services[@]}"; do + prefix="${service^^}" + prefix="${prefix//-/_}" + port_variable="${prefix}_APP_PORT" + port="${!port_variable:-}" + [ -n "$port" ] || fail "$port_variable is required in the rendered service settings" + check_url "$service" "http://127.0.0.1:${port}/health/" +done +echo "iSkyLIMS deployment smoke test passed."