diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af3af2c --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Copy this file to .env before running Docker Compose: +# cp .env.example .env + +# Published image to run. Use a different repository or tag if you are testing +# a fork or a specific release. +PYMC_REPEATER_IMAGE=pymcdev/pymc-repeater:main + +# Storage defaults to Docker named volumes. This is the safest option for +# Portainer and fresh installs because Docker preserves the image ownership. +# To use host bind mounts instead, create the folders first and make them +# writable by UID/GID 15888: +# sudo mkdir -p /opt/pymc-repeater/config /opt/pymc-repeater/data +# sudo chown -R 15888:15888 /opt/pymc-repeater/config /opt/pymc-repeater/data +# Then uncomment and adjust these paths: +# PYMC_CONFIG_VOLUME=/opt/pymc-repeater/config +# PYMC_DATA_VOLUME=/opt/pymc-repeater/data + +# SPI/GPIO access uses the host's numeric group IDs. Check your host with: +# getent group gpio +# getent group spi +# Example output: +# gpio:x:997: +# spi:x:999: +# Put the third field from each output line below. +GPIO_GID=986 +SPI_GID=989 + +# Local build only. These are used by docker-compose.build.yml if you build the +# image yourself instead of pulling PYMC_REPEATER_IMAGE. +PUID=15888 +PGID=15888 diff --git a/.gitignore b/.gitignore index 03a1ce0..3942f65 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ htmlcov/ *~ # Config +.env config.yaml config.yaml.backup identity.json diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..0f1c2f6 --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,11 @@ +services: + pymc-repeater: + image: pymc-repeater:local + build: + context: . + dockerfile: dockerfile + args: + PUID: ${PUID:-15888} + PGID: ${PGID:-15888} + GPIO_GID: ${GPIO_GID:-986} + SPI_GID: ${SPI_GID:-989} diff --git a/docker-compose.yml b/docker-compose.yml index 647db59..d2aff30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,6 @@ services: pymc-repeater: - build: - context: . - args: - PUID: ${PUID:-1000} - PGID: ${PGID:-1000} + image: ${PYMC_REPEATER_IMAGE:-pymcdev/pymc-repeater:main} container_name: pymc-repeater restart: unless-stopped ports: @@ -18,9 +14,15 @@ services: # SPI DEVICES PERMISSIONS cap_add: - SYS_RAWIO - # USB DEVICSE PERMISSIONS + # USB DEVICE PERMISSIONS group_add: - - plugdev + - "${GPIO_GID:-986}" + - "${SPI_GID:-989}" + - plugdev volumes: - - ./config:/etc/pymc_repeater - - ./data:/var/lib/pymc_repeater + - ${PYMC_CONFIG_VOLUME:-pymc-repeater-config}:/etc/pymc_repeater + - ${PYMC_DATA_VOLUME:-pymc-repeater-data}:/var/lib/pymc_repeater + +volumes: + pymc-repeater-config: + pymc-repeater-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 16788ac..eb17104 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -13,16 +13,48 @@ YQ_CMD="${YQ_CMD:-/usr/local/bin/yq}" mkdir -p "${CONFIG_DIR}" +print_permission_help() { + echo "If you are bind-mounting ./config or ./data, ensure the host paths are writable by ${RUNTIME_USER} (${RUNTIME_UID}:${RUNTIME_GID})." >&2 + echo "For the default image user, run: sudo chown -R ${RUNTIME_UID}:${RUNTIME_GID} ./config ./data" >&2 +} + +fail_bad_config_mount() { + echo "Invalid Docker config mount: ${CONFIG_PATH} is a directory, but it must be the config file." >&2 + echo "This usually happens when ./config.yaml is bind-mounted before that host file exists." >&2 + echo "Use the supported folder mount instead:" >&2 + echo " - ./config:/etc/pymc_repeater" >&2 + echo "Then place the config at ./config/config.yaml." >&2 + print_permission_help + exit 1 +} + copy_or_die() { src="$1" dest="$2" if ! cp "${src}" "${dest}"; then echo "Failed to initialize ${dest} from ${src}." >&2 - echo "If you are bind-mounting ./config.yaml, ensure the host path is writable by ${RUNTIME_USER} (${RUNTIME_UID}:${RUNTIME_GID})." >&2 + print_permission_help exit 1 fi } +use_runtime_merged_config() { + src="$1" + runtime_dir="$(mktemp -d /tmp/pymc-repeater-config.XXXXXX)" + runtime_config="${runtime_dir}/config.yaml" + + if ! cp "${src}" "${runtime_config}"; then + echo "Failed to prepare temporary merged config at ${runtime_config}; keeping the existing config." >&2 + return 1 + fi + + CONFIG_PATH="${runtime_config}" + echo "Using merged config from ${CONFIG_PATH} for this container start only." >&2 + echo "Fix the bind-mounted config ownership so future upgrades can persist merged config changes." >&2 + print_permission_help + return 0 +} + merge_config_from_example() { config_path="$1" @@ -62,15 +94,26 @@ merge_config_from_example() { fi if ! cmp -s "${config_path}" "${merged_config}"; then - copy_or_die "${merged_config}" "${config_path}" + if ! cp "${merged_config}" "${config_path}"; then + echo "Failed to update ${config_path} from merged config; the bind-mounted config is not writable." >&2 + use_runtime_merged_config "${merged_config}" || true + fi fi cleanup_merge trap - EXIT HUP INT TERM } +if [ -d "${CONFIG_PATH}" ] && [ "$(basename "${CONFIG_PATH}")" = "config.yaml" ]; then + fail_bad_config_mount +fi + if [ ! -f "${EXAMPLE_PATH}" ] && [ -f "${BUNDLED_EXAMPLE_PATH}" ]; then - copy_or_die "${BUNDLED_EXAMPLE_PATH}" "${EXAMPLE_PATH}" + if ! cp "${BUNDLED_EXAMPLE_PATH}" "${EXAMPLE_PATH}"; then + echo "Could not copy bundled example config to ${EXAMPLE_PATH}; using bundled example for config merge only." >&2 + print_permission_help + EXAMPLE_PATH="${BUNDLED_EXAMPLE_PATH}" + fi fi if [ -d "${CONFIG_PATH}" ]; then diff --git a/dockerfile b/dockerfile index a2e1218..39e2f6d 100644 --- a/dockerfile +++ b/dockerfile @@ -5,6 +5,8 @@ ARG USER=repeater ARG GROUP=repeater ARG PUID=15888 ARG PGID=15888 +ARG GPIO_GID=986 +ARG SPI_GID=989 ARG TARGETARCH ARG YQ_VERSION=v4.40.5 @@ -16,7 +18,9 @@ ENV INSTALL_DIR=/opt/pymc_repeater \ PYTHONUNBUFFERED=1 \ SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYMC_REPEATER=${PACKAGE_VERSION} \ PUID=${PUID} \ - PGID=${PGID} + PGID=${PGID} \ + GPIO_GID=${GPIO_GID} \ + SPI_GID=${SPI_GID} # Install runtime dependencies only RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \ @@ -45,7 +49,10 @@ RUN arch="${TARGETARCH:-}" \ # Create the group and user in order to run without root privileges RUN groupadd --gid "$PGID" "$GROUP" \ - && useradd --uid "$PUID" --gid "$PGID" --home-dir "$HOME_DIR" --create-home --shell /usr/bin/bash "$USER" + && groupadd --gid "$GPIO_GID" gpio \ + && groupadd --gid "$SPI_GID" spi \ + && useradd --uid "$PUID" --gid "$PGID" --home-dir "$HOME_DIR" --create-home --shell /usr/bin/bash "$USER" \ + && usermod -a -G gpio,spi "$USER" # Create runtime directories RUN mkdir -p ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR} \