diff --git a/dockerized/.dockerignore b/dockerized/.dockerignore new file mode 100644 index 0000000..59087b0 --- /dev/null +++ b/dockerized/.dockerignore @@ -0,0 +1,13 @@ +.env +runtime/source/.env +runtime/source/.git +runtime/source/node_modules +runtime/source/vendor +runtime/source/public/build +runtime/source/storage/app/private/recordings +runtime/source/storage/framework/cache +runtime/source/storage/framework/sessions +runtime/source/storage/framework/testing +runtime/source/storage/framework/views +runtime/source/storage/logs + diff --git a/dockerized/.env.example b/dockerized/.env.example new file mode 100644 index 0000000..9d20802 --- /dev/null +++ b/dockerized/.env.example @@ -0,0 +1,48 @@ +COMPOSE_PROJECT_NAME=nyone + +# Git source cloned by scripts/deploy.sh into runtime/source before Docker builds. +GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git +GIT_REF=main + +# Public hostnames. TLS should terminate at your external proxy/CDN/load balancer. +PUBLIC_SCHEME=https +APP_DOMAIN=nyone.example.com +HLS_DOMAIN=nyone-hls.example.com +APP_HTTP_PORT=8080 +RTMP_PUBLIC_PORT=19935 + +APP_NAME=Nyone +APP_ENV=production +APP_DEBUG=false +APP_KEY= +APP_PREVIOUS_KEYS= +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +LOG_LEVEL=info +MAIL_MAILER=log +MAIL_FROM_ADDRESS=hello@example.com +MAIL_FROM_NAME=Nyone + +POSTGRES_DB=nyone +POSTGRES_USER=nyone +POSTGRES_PASSWORD= + +MEDIAMTX_SHARED_SECRET= +STREAMING_VIEWER_SYNC_INTERVAL=2 +STREAMING_VIEWER_COUNT_TTL=10 +STREAMING_VIEWER_IDENTITY_COOKIE=nyone_visitor_id +STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS=365 +STREAMING_VIEWER_IDENTITY_TTL=86400 +STREAMING_VIEWER_STATS_POLL_INTERVAL=30000 +STREAMING_VOD_RETENTION_DAYS=30 + +# Optional deployment behavior. +SEED_DATABASE=false +APP_OPTIMIZE_ON_BOOT=true +QUEUE_NAMES=default +QUEUE_SLEEP=3 +QUEUE_TRIES=3 +QUEUE_MAX_TIME=3600 +QUEUE_TIMEOUT=90 diff --git a/dockerized/.gitignore b/dockerized/.gitignore new file mode 100644 index 0000000..b6a6996 --- /dev/null +++ b/dockerized/.gitignore @@ -0,0 +1,3 @@ +.env +runtime/ + diff --git a/dockerized/docker-compose.yml b/dockerized/docker-compose.yml new file mode 100644 index 0000000..c5515c0 --- /dev/null +++ b/dockerized/docker-compose.yml @@ -0,0 +1,188 @@ +name: ${COMPOSE_PROJECT_NAME:-nyone-service} + +x-app-environment: &app-environment + APP_NAME: ${APP_NAME:-Nyone} + APP_ENV: ${APP_ENV:-production} + APP_DEBUG: ${APP_DEBUG:-false} + APP_KEY: ${APP_KEY:-} + APP_PREVIOUS_KEYS: ${APP_PREVIOUS_KEYS:-} + APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com} + APP_LOCALE: ${APP_LOCALE:-en} + APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-en} + APP_FAKER_LOCALE: ${APP_FAKER_LOCALE:-en_US} + APP_MAINTENANCE_DRIVER: file + BCRYPT_ROUNDS: 12 + LOG_CHANNEL: stack + LOG_STACK: stderr + LOG_DEPRECATIONS_CHANNEL: "null" + LOG_LEVEL: ${LOG_LEVEL:-info} + DB_CONNECTION: pgsql + DB_HOST: db + DB_PORT: 5432 + DB_DATABASE: ${POSTGRES_DB:-nyone} + DB_USERNAME: ${POSTGRES_USER:-nyone} + DB_PASSWORD: ${POSTGRES_PASSWORD:-} + SESSION_DRIVER: database + SESSION_LIFETIME: 120 + SESSION_ENCRYPT: "false" + SESSION_PATH: / + SESSION_DOMAIN: ${SESSION_DOMAIN:-} + SESSION_SECURE_COOKIE: ${SESSION_SECURE_COOKIE:-true} + BROADCAST_CONNECTION: log + FILESYSTEM_DISK: local + QUEUE_CONNECTION: database + CACHE_STORE: redis + REDIS_CLIENT: phpredis + REDIS_HOST: redis + REDIS_PORT: 6379 + MAIL_MAILER: ${MAIL_MAILER:-log} + MAIL_SCHEME: ${MAIL_SCHEME:-} + MAIL_HOST: ${MAIL_HOST:-127.0.0.1} + MAIL_PORT: ${MAIL_PORT:-2525} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_PASSWORD: ${MAIL_PASSWORD:-} + MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-hello@example.com} + MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Nyone} + VITE_APP_NAME: ${APP_NAME:-Nyone} + STREAMING_RTMP_INGEST_URL: rtmp://${APP_DOMAIN:-nyone.example.com}:${RTMP_PUBLIC_PORT:-19935} + STREAMING_HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com} + STREAMING_MEDIAMTX_API_URL: http://mediamtx:9997 + STREAMING_VIEWER_COUNT_CACHE_STORE: redis + STREAMING_VIEWER_COUNT_TTL: ${STREAMING_VIEWER_COUNT_TTL:-10} + STREAMING_VIEWER_SYNC_INTERVAL: ${STREAMING_VIEWER_SYNC_INTERVAL:-2} + STREAMING_VIEWER_IDENTITY_COOKIE: ${STREAMING_VIEWER_IDENTITY_COOKIE:-nyone_visitor_id} + STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS: ${STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS:-365} + STREAMING_VIEWER_IDENTITY_TTL: ${STREAMING_VIEWER_IDENTITY_TTL:-86400} + STREAMING_VIEWER_STATS_POLL_INTERVAL: ${STREAMING_VIEWER_STATS_POLL_INTERVAL:-30000} + STREAMING_VOD_RETENTION_DAYS: ${STREAMING_VOD_RETENTION_DAYS:-30} + STREAMING_RECORDINGS_PATH: /var/www/html/storage/app/private/recordings + MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-} + APP_DOMAIN: ${APP_DOMAIN:-nyone.example.com} + HLS_DOMAIN: ${HLS_DOMAIN:-nyone-hls.example.com} + HLS_INTERNAL_PORT: 19988 + APP_OPTIMIZE_ON_BOOT: ${APP_OPTIMIZE_ON_BOOT:-true} + SEED_DATABASE: ${SEED_DATABASE:-false} + +x-app-service: &app-service + image: ${APP_IMAGE:-nyone-app:latest} + build: + context: . + dockerfile: docker/app/Dockerfile + restart: unless-stopped + environment: + <<: *app-environment + volumes: + - app-storage:/var/www/html/storage + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + +services: + app: + <<: *app-service + environment: + <<: *app-environment + CONTAINER_ROLE: app + ports: + - "${APP_HTTP_PORT:-8080}:80" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/up >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + migrate: + <<: *app-service + restart: "no" + environment: + <<: *app-environment + CONTAINER_ROLE: migrate + profiles: + - tools + + queue: + <<: *app-service + environment: + <<: *app-environment + CONTAINER_ROLE: queue + QUEUE_NAMES: ${QUEUE_NAMES:-default} + QUEUE_SLEEP: ${QUEUE_SLEEP:-3} + QUEUE_TRIES: ${QUEUE_TRIES:-3} + QUEUE_MAX_TIME: ${QUEUE_MAX_TIME:-3600} + QUEUE_TIMEOUT: ${QUEUE_TIMEOUT:-90} + + scheduler: + <<: *app-service + environment: + <<: *app-environment + CONTAINER_ROLE: scheduler + + viewer-sync: + <<: *app-service + environment: + <<: *app-environment + CONTAINER_ROLE: viewer-sync + MEDIAMTX_HOST: mediamtx + MEDIAMTX_API_PORT: 9997 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + mediamtx: + condition: service_started + + mediamtx: + image: ${MEDIAMTX_IMAGE:-nyone-mediamtx:latest} + build: + context: . + dockerfile: docker/mediamtx/Dockerfile + restart: unless-stopped + environment: + APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com} + HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com} + MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-} + RTMP_INTERNAL_PORT: 19935 + HLS_INTERNAL_PORT: 19988 + ports: + - "${RTMP_PUBLIC_PORT:-19935}:19935" + volumes: + - app-storage:/var/www/html/storage + depends_on: + app: + condition: service_started + + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-nyone} + POSTGRES_USER: ${POSTGRES_USER:-nyone} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + app-storage: + postgres-data: + redis-data: diff --git a/dockerized/docker/app/Dockerfile b/dockerized/docker/app/Dockerfile new file mode 100644 index 0000000..5bc197f --- /dev/null +++ b/dockerized/docker/app/Dockerfile @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS assets + +WORKDIR /build + +COPY runtime/source/package.json runtime/source/package-lock.json ./ +RUN npm ci + +COPY runtime/source/ ./ +RUN npm run build + +FROM php:8.4-apache-bookworm AS app + +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \ + COMPOSER_ALLOW_SUPERUSER=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gettext-base \ + git \ + gosu \ + libicu-dev \ + libcurl4-openssl-dev \ + libonig-dev \ + libpq-dev \ + libzip-dev \ + netcat-openbsd \ + unzip \ + && docker-php-ext-install -j"$(nproc)" \ + bcmath \ + curl \ + intl \ + mbstring \ + opcache \ + pcntl \ + pdo_pgsql \ + pgsql \ + zip \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && a2enmod headers proxy proxy_http rewrite setenvif \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +COPY runtime/source/composer.json runtime/source/composer.lock ./ +RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts + +COPY runtime/source/ ./ +COPY --from=assets /build/public/build ./public/build + +COPY docker/app/apache/nyone.conf.template /etc/apache2/sites-available/nyone.conf.template +COPY docker/app/php/production.ini /usr/local/etc/php/conf.d/production.ini +COPY docker/app/entrypoint.sh /usr/local/bin/nyone-entrypoint + +RUN composer dump-autoload --optimize --no-dev \ + && php artisan package:discover --ansi \ + && mkdir -p \ + storage/app/private/recordings \ + storage/app/public \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache \ + && chmod +x /usr/local/bin/nyone-entrypoint + +ENTRYPOINT ["nyone-entrypoint"] +CMD ["app"] diff --git a/dockerized/docker/app/apache/nyone.conf.template b/dockerized/docker/app/apache/nyone.conf.template new file mode 100644 index 0000000..40593a3 --- /dev/null +++ b/dockerized/docker/app/apache/nyone.conf.template @@ -0,0 +1,43 @@ +ServerName ${APP_DOMAIN} + + + ServerName ${APP_DOMAIN} + + DocumentRoot /var/www/html/public + DirectoryIndex index.php + + SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on + SetEnvIf X-Forwarded-Ssl "^on$" HTTPS=on + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + + SetHandler application/x-httpd-php + + + ErrorLog /proc/self/fd/2 + CustomLog /proc/self/fd/1 combined + + + + ServerName ${HLS_DOMAIN} + + ProxyRequests Off + ProxyPreserveHost On + + ProxyPass "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" retry=0 + ProxyPassReverse "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" + + Header always set Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" + Header always set Access-Control-Allow-Origin "${APP_URL}" + Header always set Access-Control-Allow-Methods "GET, HEAD, OPTIONS" + Header always set Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" + + ErrorLog /proc/self/fd/2 + CustomLog /proc/self/fd/1 combined + + diff --git a/dockerized/docker/app/entrypoint.sh b/dockerized/docker/app/entrypoint.sh new file mode 100644 index 0000000..1a5a364 --- /dev/null +++ b/dockerized/docker/app/entrypoint.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /var/www/html + +role="${CONTAINER_ROLE:-${1:-app}}" + +fail() { + echo "nyone-entrypoint: $*" >&2 + exit 1 +} + +require_env() { + local key="$1" + local value="${!key:-}" + + if [[ -z "$value" || "$value" == "change-me"* || "$value" == "base64:change-me"* ]]; then + fail "$key is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env." + fi +} + +wait_for() { + local host="$1" + local port="$2" + local label="$3" + local attempts="${4:-60}" + + for ((i = 1; i <= attempts; i++)); do + if nc -z "$host" "$port" >/dev/null 2>&1; then + return 0 + fi + + sleep 2 + done + + fail "Timed out waiting for $label at $host:$port." +} + +artisan() { + gosu www-data php artisan "$@" +} + +render_apache_config() { + export APP_DOMAIN HLS_DOMAIN HLS_INTERNAL_PORT APP_URL + + envsubst '${APP_DOMAIN} ${HLS_DOMAIN} ${HLS_INTERNAL_PORT} ${APP_URL}' \ + < /etc/apache2/sites-available/nyone.conf.template \ + > /etc/apache2/sites-available/000-default.conf +} + +prepare_laravel_paths() { + mkdir -p \ + storage/app/private/recordings \ + storage/app/public \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache + + chown -R www-data:www-data storage bootstrap/cache +} + +cache_laravel_bootstrap() { + if [[ "${APP_OPTIMIZE_ON_BOOT:-true}" != "true" ]]; then + return 0 + fi + + artisan config:clear + artisan route:clear + artisan view:clear + artisan event:clear + artisan config:cache + artisan route:cache + artisan view:cache +} + +require_env APP_KEY +require_env APP_DOMAIN +require_env HLS_DOMAIN +require_env MEDIAMTX_SHARED_SECRET +require_env DB_DATABASE +require_env DB_USERNAME +require_env DB_PASSWORD + +if [[ "${WAIT_FOR_SERVICES:-true}" == "true" ]]; then + wait_for "${DB_HOST:-db}" "${DB_PORT:-5432}" "PostgreSQL" + wait_for "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" "Redis" + + if [[ "$role" == "viewer-sync" ]]; then + wait_for "${MEDIAMTX_HOST:-mediamtx}" "${MEDIAMTX_API_PORT:-9997}" "MediaMTX API" + fi +fi + +prepare_laravel_paths +artisan storage:link --force >/dev/null || true +cache_laravel_bootstrap + +case "$role" in + app) + render_apache_config + exec apache2-foreground + ;; + migrate) + artisan migrate --force + + if [[ "${SEED_DATABASE:-false}" == "true" ]]; then + artisan db:seed --force + fi + ;; + queue) + exec gosu www-data php artisan queue:work "${QUEUE_CONNECTION:-database}" \ + --queue="${QUEUE_NAMES:-default}" \ + --sleep="${QUEUE_SLEEP:-3}" \ + --tries="${QUEUE_TRIES:-3}" \ + --max-time="${QUEUE_MAX_TIME:-3600}" \ + --timeout="${QUEUE_TIMEOUT:-90}" + ;; + scheduler) + exec gosu www-data php artisan schedule:work + ;; + viewer-sync) + exec gosu www-data php artisan streaming:sync-viewer-counts \ + --isolated \ + --interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}" + ;; + artisan) + shift || true + exec gosu www-data php artisan "$@" + ;; + *) + exec "$@" + ;; +esac diff --git a/dockerized/docker/app/php/production.ini b/dockerized/docker/app/php/production.ini new file mode 100644 index 0000000..2358aec --- /dev/null +++ b/dockerized/docker/app/php/production.ini @@ -0,0 +1,17 @@ +expose_php=Off +memory_limit=512M +upload_max_filesize=100M +post_max_size=100M +max_execution_time=120 +max_input_vars=3000 +variables_order=EGPCS + +opcache.enable=1 +opcache.enable_cli=1 +opcache.memory_consumption=256 +opcache.interned_strings_buffer=32 +opcache.max_accelerated_files=20000 +opcache.validate_timestamps=0 +opcache.save_comments=1 +opcache.jit=0 + diff --git a/dockerized/docker/mediamtx/Dockerfile b/dockerized/docker/mediamtx/Dockerfile new file mode 100644 index 0000000..9ea2dfd --- /dev/null +++ b/dockerized/docker/mediamtx/Dockerfile @@ -0,0 +1,17 @@ +FROM bluenviron/mediamtx:latest-ffmpeg + +USER root + +RUN (apk add --no-cache curl gettext >/dev/null 2>&1) \ + || (apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gettext-base \ + && rm -rf /var/lib/apt/lists/*) + +COPY docker/mediamtx/entrypoint.sh /usr/local/bin/nyone-mediamtx-entrypoint +COPY docker/mediamtx/mediamtx.yml.template /etc/mediamtx/mediamtx.yml.template + +RUN chmod +x /usr/local/bin/nyone-mediamtx-entrypoint \ + && mkdir -p /config /var/www/html/storage/app/private/recordings + +ENTRYPOINT ["nyone-mediamtx-entrypoint"] + diff --git a/dockerized/docker/mediamtx/entrypoint.sh b/dockerized/docker/mediamtx/entrypoint.sh new file mode 100644 index 0000000..63c03b6 --- /dev/null +++ b/dockerized/docker/mediamtx/entrypoint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +fail() { + echo "nyone-mediamtx-entrypoint: $*" >&2 + exit 1 +} + +if [ -z "${MEDIAMTX_SHARED_SECRET:-}" ] || [ "${MEDIAMTX_SHARED_SECRET#change-me}" != "$MEDIAMTX_SHARED_SECRET" ]; then + fail "MEDIAMTX_SHARED_SECRET is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env." +fi + +: "${APP_URL:?APP_URL is required.}" +: "${HLS_PUBLIC_URL:?HLS_PUBLIC_URL is required.}" +: "${RTMP_INTERNAL_PORT:=19935}" +: "${HLS_INTERNAL_PORT:=19988}" + +mkdir -p /config /var/www/html/storage/app/private/recordings + +export APP_URL HLS_PUBLIC_URL MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT + +envsubst '${APP_URL} ${HLS_PUBLIC_URL} ${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \ + < /etc/mediamtx/mediamtx.yml.template \ + > /config/mediamtx.yml + +if command -v mediamtx >/dev/null 2>&1; then + exec mediamtx /config/mediamtx.yml +fi + +if [ -x /mediamtx ]; then + exec /mediamtx /config/mediamtx.yml +fi + +fail "MediaMTX binary was not found in the base image." + diff --git a/dockerized/docker/mediamtx/mediamtx.yml.template b/dockerized/docker/mediamtx/mediamtx.yml.template new file mode 100644 index 0000000..c936810 --- /dev/null +++ b/dockerized/docker/mediamtx/mediamtx.yml.template @@ -0,0 +1,35 @@ +authMethod: http +authHTTPAddress: http://app/internal/mediamtx/auth?secret=${MEDIAMTX_SHARED_SECRET} +authHTTPExclude: + - action: read + - action: playback + - action: api + - action: metrics + - action: pprof + +api: yes +apiAddress: :9997 + +rtmp: yes +rtmpAddress: :${RTMP_INTERNAL_PORT} + +hls: yes +hlsAddress: :${HLS_INTERNAL_PORT} +hlsVariant: mpegts +hlsAllowOrigins: + - ${APP_URL} + - ${HLS_PUBLIC_URL} + +pathDefaults: + record: yes + recordPath: /var/www/html/storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f + + runOnReady: curl -fsS -X POST "http://app/internal/mediamtx/ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID" + runOnReadyRestart: no + runOnNotReady: curl -fsS -X POST "http://app/internal/mediamtx/not-ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID" + runOnRecordSegmentComplete: curl -fsS -X POST "http://app/internal/mediamtx/recording-complete?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION" + +paths: + all_others: + source: publisher + diff --git a/dockerized/scripts/deploy.sh b/dockerized/scripts/deploy.sh new file mode 100644 index 0000000..bfb67b1 --- /dev/null +++ b/dockerized/scripts/deploy.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT_DIR/.env" +EXAMPLE_ENV_FILE="$ROOT_DIR/.env.example" +SOURCE_DIR="$ROOT_DIR/runtime/source" +COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" + +info() { + printf '[nyone-deploy] %s\n' "$*" +} + +fail() { + printf '[nyone-deploy] ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required." +} + +env_value() { + local key="$1" + + if [ ! -f "$ENV_FILE" ]; then + return 0 + fi + + awk -v key="$key" ' + index($0, key "=") == 1 { + value = substr($0, length(key) + 2) + } + END { + print value + } + ' "$ENV_FILE" +} + +set_env_value() { + local key="$1" + local value="$2" + local escaped + + escaped="$(printf '%s' "$value" | sed 's/[\/&]/\\&/g')" + + if grep -qE "^${key}=" "$ENV_FILE"; then + sed -i.bak "s/^${key}=.*/${key}=${escaped}/" "$ENV_FILE" + rm -f "$ENV_FILE.bak" + else + printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE" + fi +} + +is_missing_secret() { + local value="$1" + + [ -z "$value" ] || [[ "$value" == "change-me"* ]] || [[ "$value" == "base64:change-me"* ]] +} + +random_secret() { + openssl rand -base64 48 | tr -d '\n' | tr '/+' '_-' | tr -d '=' +} + +ensure_env_file() { + if [ -f "$ENV_FILE" ]; then + return 0 + fi + + [ -f "$EXAMPLE_ENV_FILE" ] || fail ".env.example was not found." + cp "$EXAMPLE_ENV_FILE" "$ENV_FILE" + info "Created .env from .env.example." +} + +ensure_secret() { + local key="$1" + local value + + value="$(env_value "$key")" + + if is_missing_secret "$value"; then + set_env_value "$key" "$(random_secret)" + info "Generated $key." + fi +} + +require_config_value() { + local key="$1" + local placeholder="${2:-}" + local value + + value="$(env_value "$key")" + + [ -n "$value" ] || fail "Set $key in $ENV_FILE." + + if [ -n "$placeholder" ] && [ "$value" = "$placeholder" ]; then + fail "Replace the placeholder $key in $ENV_FILE." + fi +} + +ensure_app_key() { + local value + + value="$(env_value APP_KEY)" + + if is_missing_secret "$value"; then + set_env_value APP_KEY "base64:$(openssl rand -base64 32 | tr -d '\n')" + info "Generated APP_KEY." + fi +} + +clone_or_update_source() { + local repository_url="$1" + local git_ref="$2" + + mkdir -p "$(dirname "$SOURCE_DIR")" + + if [ -d "$SOURCE_DIR/.git" ]; then + info "Updating existing source checkout." + git -C "$SOURCE_DIR" fetch --prune --tags origin + + if git -C "$SOURCE_DIR" rev-parse --verify --quiet "origin/$git_ref" >/dev/null; then + git -C "$SOURCE_DIR" checkout -B "$git_ref" "origin/$git_ref" + else + git -C "$SOURCE_DIR" checkout "$git_ref" + fi + + return 0 + fi + + if [ -e "$SOURCE_DIR" ]; then + fail "$SOURCE_DIR exists but is not a Git checkout. Move it away or remove it before deploying." + fi + + info "Cloning application repository." + git clone "$repository_url" "$SOURCE_DIR" + git -C "$SOURCE_DIR" checkout "$git_ref" +} + +run_compose() { + docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +require_command git +require_command docker +require_command openssl + +ensure_env_file +ensure_app_key +ensure_secret POSTGRES_PASSWORD +ensure_secret MEDIAMTX_SHARED_SECRET + +GIT_REPOSITORY_URL="$(env_value GIT_REPOSITORY_URL)" +GIT_REF="$(env_value GIT_REF)" + +require_config_value GIT_REPOSITORY_URL "https://github.com/your-org/nyone.git" +require_config_value APP_DOMAIN "nyone.example.com" +require_config_value HLS_DOMAIN "nyone-hls.example.com" + +if [ "$(env_value APP_DOMAIN)" = "$(env_value HLS_DOMAIN)" ]; then + fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate Apache virtual hosts." +fi + +GIT_REF="${GIT_REF:-main}" + +clone_or_update_source "$GIT_REPOSITORY_URL" "$GIT_REF" + +info "Validating Compose configuration." +run_compose config >/dev/null + +info "Building Docker images." +run_compose build + +info "Starting database and Redis." +run_compose up -d db redis + +info "Running migrations." +run_compose --profile tools run --rm migrate + +info "Starting background workers." +run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync + +info "Current container status:" +run_compose ps