diff --git a/Dockerfile b/Dockerfile index 1e22375..72888d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,57 @@ +ARG NODE_VERSION=24 +ARG PYTHON_VERSION=3.14-slim + +############# +# build api # +############# + +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION} AS build-api + +# env setup +WORKDIR /usr/local/src/advent22_api +ENV \ + PATH="/root/.local/bin:${PATH}" + +# install poetry with export plugin +RUN set -ex; \ + \ + python -m pip --no-cache-dir install --upgrade pip wheel; \ + \ + apt-get update; apt-get install --yes --no-install-recommends \ + curl \ + ; rm -rf /var/lib/apt/lists/*; \ + \ + curl -sSL https://install.python-poetry.org | python3 -; \ + poetry self add poetry-plugin-export; + +# build dependency wheels +COPY api/pyproject.toml api/poetry.lock ./ +RUN set -ex; \ + \ + # # buildtime dependencies + # apt-get update; apt-get install --yes --no-install-recommends \ + # build-essential \ + # ; rm -rf /var/lib/apt/lists/*; \ + \ + # generate requirements.txt + poetry export \ + --format requirements.txt \ + --output requirements.txt; \ + \ + python3 -m pip --no-cache-dir wheel \ + --wheel-dir ./dist \ + --requirement requirements.txt; + +# build advent22_api wheel +COPY api ./ +RUN poetry build --format wheel --output ./dist + ############ # build ui # ############ -ARG NODE_VERSION=18.18 -ARG PYTHON_VERSION=3.11-slim +ARG NODE_VERSION FROM node:${NODE_VERSION} AS build-ui # env setup @@ -11,34 +59,68 @@ WORKDIR /usr/local/src/advent22_ui # install advent22_ui dependencies COPY ui/package*.json ui/yarn*.lock ./ -RUN yarn install --production false +RUN set -ex; \ + corepack enable; \ + yarn install; # copy and build advent22_ui COPY ui ./ -RUN yarn build --dest /tmp/advent22_ui/html +RUN set -ex; \ + yarn dlx update-browserslist-db@latest; \ + yarn build --dest /tmp/advent22_ui/html; \ + # exclude webpack-bundle-analyzer output + rm -f /tmp/advent22_ui/html/report.html; + +###################### +# python preparation # +###################### + +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION} AS uvicorn-gunicorn + +# where credit is due ... +LABEL maintainer="Sebastián Ramirez " +WORKDIR /usr/local/share/uvicorn-gunicorn + +# install uvicorn-gunicorn +COPY ./scripts/mini-tiangolo ./ + +RUN set -ex; \ + chmod +x start.sh; \ + python3 -m pip --no-cache-dir install gunicorn; + +CMD ["/usr/local/share/uvicorn-gunicorn/start.sh"] ########### # web app # ########### -ARG PYTHON_VERSION -FROM tiangolo/uvicorn-gunicorn:python${PYTHON_VERSION} AS production +FROM uvicorn-gunicorn AS production # env setup -WORKDIR /usr/local/src/advent22_api ENV \ PRODUCTION_MODE="true" \ PORT="8000" \ MODULE_NAME="advent22_api.app" EXPOSE 8000 -# install advent22_api -COPY api ./ +WORKDIR /opt/advent22 +VOLUME [ "/opt/advent22" ] + +COPY --from=build-api /usr/local/src/advent22_api/dist /usr/local/share/advent22_api.dist RUN set -ex; \ # remove example app rm -rf /app; \ \ - python -m pip --no-cache-dir install ./ + # # runtime dependencies + # apt-get update; apt-get install --yes --no-install-recommends \ + # ; rm -rf /var/lib/apt/lists/*; \ + \ + # install advent22_api wheels + python3 -m pip --no-cache-dir install --no-deps /usr/local/share/advent22_api.dist/*.whl; \ + \ + # prepare data directory + chown nobody:nogroup ./ # add prepared advent22_ui COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui diff --git a/scripts/check_version b/scripts/check_version new file mode 100755 index 0000000..c65ffcb --- /dev/null +++ b/scripts/check_version @@ -0,0 +1,61 @@ +#!/bin/sh + +script="$( readlink -f "${0}" )" +script_dir="$( dirname "${script}" )" + +git rev-parse --abbrev-ref HEAD | grep -E '^develop$|^feature/' >/dev/null \ + && git_status="developing" +git rev-parse --abbrev-ref HEAD | grep -E '^release/|^hotfix/' >/dev/null \ + && git_status="releasing" +git rev-parse --abbrev-ref HEAD | grep -E '^master$' >/dev/null \ + && git_status="released" + + +if [ "${git_status}" = "developing" ]; then + echo "Status: Developing" + # => version from most recent tag + git_version="$( \ + git describe --tags --abbrev=0 \ + | sed -E 's/^v[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/' + )" +elif [ "${git_status}" = "releasing" ]; then + echo "Status: Releasing" + # => version from releasing branch + git_version="$( \ + git rev-parse --abbrev-ref HEAD \ + | cut -d '/' -f 2 + )" +elif [ "${git_status}" = "released" ]; then + echo "Status: Released" + # => version from current tag + git_version="$( \ + git describe --tags \ + | sed -E 's/^v[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9])$/\1/' + )" +else + echo "ERROR: Invalid git branch" + echo "ERROR: Chores cannot be run on '$( git rev-parse --abbrev-ref HEAD )'!" + exit 2 +fi + +api_version="$( \ + grep '^version' "${script_dir}/../api/pyproject.toml" \ + | sed -E 's/^version[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/' +)" + +ui_version="$( \ + grep '"version":' "${script_dir}/../ui/package.json" \ + | sed -E 's/.*"version":[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/' +)" + +if [ "${git_version}" = "${api_version}" ] \ +&& [ "${git_version}" = "${ui_version}" ]; then + mark="✅️" +else + mark="❌️" +fi + +echo "git: ${git_version}, api: ${api_version}, ui: ${ui_version}" +echo ">>>>> RESULT: ${mark} <<<<<" + +[ "${mark}" = "✅️" ] || exit 1 diff --git a/scripts/mini-tiangolo/gunicorn_conf.py b/scripts/mini-tiangolo/gunicorn_conf.py new file mode 100644 index 0000000..7dd141d --- /dev/null +++ b/scripts/mini-tiangolo/gunicorn_conf.py @@ -0,0 +1,67 @@ +import json +import multiprocessing +import os + +workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") +max_workers_str = os.getenv("MAX_WORKERS") +use_max_workers = None +if max_workers_str: + use_max_workers = int(max_workers_str) +web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) + +host = os.getenv("HOST", "0.0.0.0") +port = os.getenv("PORT", "80") +bind_env = os.getenv("BIND", None) +use_loglevel = os.getenv("LOG_LEVEL", "info") +if bind_env: + use_bind = bind_env +else: + use_bind = f"{host}:{port}" + +cores = multiprocessing.cpu_count() +workers_per_core = float(workers_per_core_str) +default_web_concurrency = workers_per_core * cores +if web_concurrency_str: + web_concurrency = int(web_concurrency_str) + assert web_concurrency > 0 +else: + web_concurrency = max(int(default_web_concurrency), 2) + if use_max_workers: + web_concurrency = min(web_concurrency, use_max_workers) +accesslog_var = os.getenv("ACCESS_LOG", "-") +use_accesslog = accesslog_var or None +errorlog_var = os.getenv("ERROR_LOG", "-") +use_errorlog = errorlog_var or None +graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120") +timeout_str = os.getenv("TIMEOUT", "120") +keepalive_str = os.getenv("KEEP_ALIVE", "5") + +# Gunicorn config variables +loglevel = use_loglevel +workers = web_concurrency +bind = use_bind +errorlog = use_errorlog +worker_tmp_dir = "/dev/shm" +accesslog = use_accesslog +graceful_timeout = int(graceful_timeout_str) +timeout = int(timeout_str) +keepalive = int(keepalive_str) + + +# For debugging and testing +log_data = { + "loglevel": loglevel, + "workers": workers, + "bind": bind, + "graceful_timeout": graceful_timeout, + "timeout": timeout, + "keepalive": keepalive, + "errorlog": errorlog, + "accesslog": accesslog, + # Additional, non-gunicorn variables + "workers_per_core": workers_per_core, + "use_max_workers": use_max_workers, + "host": host, + "port": port, +} +print(json.dumps(log_data)) diff --git a/scripts/mini-tiangolo/start.sh b/scripts/mini-tiangolo/start.sh new file mode 100644 index 0000000..b72737b --- /dev/null +++ b/scripts/mini-tiangolo/start.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +MODULE_NAME=${MODULE_NAME:-"app.main"} +VARIABLE_NAME=${VARIABLE_NAME:-"app"} +export APP_MODULE="${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"}" +export GUNICORN_CONF="${GUNICORN_CONF:-"/usr/local/share/uvicorn-gunicorn/gunicorn_conf.py"}" +export WORKER_CLASS="${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"}" + +if [ -f "${PRE_START_PATH}" ] ; then + echo "Running script ${PRE_START_PATH}" + # shellcheck disable=SC1090 + . "${PRE_START_PATH}" +fi + +# Start Gunicorn +exec gunicorn \ + -k "${WORKER_CLASS}" \ + -c "${GUNICORN_CONF}" \ + "${APP_MODULE}" diff --git a/scripts/publish b/scripts/publish new file mode 100755 index 0000000..fa9b1af --- /dev/null +++ b/scripts/publish @@ -0,0 +1,22 @@ +#!/bin/bash + +script="$( readlink -f "${0}" )" +script_dir="$( dirname "${script}" )" + +# shellcheck disable=SC1091 +. "${script_dir}/check_version" + +# vars defined in `check_version` script +# shellcheck disable=SC2154 +if [ "${git_status}" = "releasing" ] || [ "${git_status}" = "released" ]; then + # shellcheck disable=SC2154 + image_tag="${git_version}" +else + image_tag="latest" +fi + +docker buildx build \ + --pull --push \ + --tag "code.lenaisten.de/lenaisten/advent22:${image_tag}" \ + --platform "linux/amd64" \ + "${script_dir}/.."