#!/usr/bin/env bash ########################################################################################## # Скрипт управления проектом iptv.axenov.dev # # Copyright (c) 2025 Антон Аксенов # MIT License, see LICENSE file for more info. ########################################################################################## # shellcheck disable=SC2015,SC2103,SC2164,SC2155 set -o pipefail ######################################################## # Служебные исходные переменные ######################################################## if [[ "${BASH_SOURCE[*]}" ]]; then [[ "${BASH_SOURCE[0]}" != "$0" ]] && echo "*** Команда source iptv запрещена ***" && exit 40 ROOT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" else # script running from stdin ROOT_PATH="$(pwd)" fi IPTV_PROJECTS=("iptvc" "docs" "web" "playlists") IPTV_GITEA_URL_SSH="git@git.axenov.dev:IPTV" IPTV_GITEA_URL_HTTPS="https://git.axenov.dev/IPTV" IPTV_DOCKER_URL_SSH="$IPTV_GITEA_URL_SSH/iptv-docker.git" for sig in SIGHUP SIGINT SIGQUIT SIGABRT SIGKILL SIGTERM SIGTSTP; do # https://faculty.cs.niu.edu/~hutchins/csci480/signals.htm # shellcheck disable=SC2064 trap "set +x && echo && echo && echo '*** Прервано сигналом $sig, остановка ***' && exit" $sig done [[ $IPTV_DEBUG == 1 ]] && DEBUG_MODE=1 [[ $IPTV_DEBUG -gt 1 ]] && set -x RAW_ARGS=("$@") COMMAND="$1" shift ######################################################## # Ввод/вывод ######################################################## which tput > /dev/null 2>&1 && [ "$(tput -T"$TERM" colors)" -gt 8 ] && CAN_USE_COLORS=1 || CAN_USE_COLORS=0 IPTV_COLORS=${IPTV_COLORS:-$CAN_USE_COLORS} [[ $IPTV_COLORS == 1 ]] && FBOLD="$(tput bold)" || FBOLD='' [[ $IPTV_COLORS == 1 ]] && FDIM="$(tput dim)" || FDIM='' [[ $IPTV_COLORS == 1 ]] && FRESET="$(tput sgr0)" || FRESET='' ask() { IFS= read -rp "$(print "${FBOLD}$1" ): " "$2" } print() { echo -e "$*${FRESET}" } debug() { [[ "$DEBUG_MODE" != 1 ]] && return if [ "$2" ]; then print "${FDIM}> ${FUNCNAME[1]:-?}():${BASH_LINENO:-?}\t$* ${FRESET}" >&2 else print "${FDIM}> $* ${FRESET}" >&2 fi } var_dump() { debug "$1 = ${!1}" } inform() { print "$*${FRESET}" } subtitle() { echo inform "${FBOLD}$*${FRESET}" } title() { subtitle "$@" echo } success() { print "${FBOLD}$*${FRESET}" } warn() { print "${FBOLD}Внимание!${FRESET} $*${FRESET}" } error() { print "${FBOLD}Ошибка:${FRESET} $*${FRESET}" >&2 print_stacktrace } die() { error "${1:-halted}" exit "${2:-255}" } print_stacktrace() { [[ "$DEBUG_MODE" != 1 ]] && return local i local stack_size=${#FUNCNAME[@]} debug "Callstack:" # for (( i=$stack_size-1; i>=1; i-- )); do for (( i=1; i /dev/null } # Проверяет соответствие строки $1 регулярному выражению $2 regex_match() { [[ "$1" =~ ^$2$ ]] } # Проверяет соответствие строки $1 регулярному выражению $2 с помощью grep grep_match() { printf "%s" "$1" | grep -qE "$2" >/dev/null 2>&1 } # Возвращает название текущей ОС get_os() { case "$(uname -s)" in Linux*) echo Linux ;; Darwin*) echo Macos ;; CYGWIN*) echo Cygwin ;; MINGW*) echo MinGw ;; MSYS_NT*) echo Git ;; *) return 1 ;; esac } # Обрезает пробельные символы с начала и с конца строки trim() { echo "$1" | xargs } ######################################################## # Функции парсинга аргументов # https://gist.axenov.dev/anthony/sh-args ######################################################## # Парсит короткий аргумент arg() { [ "$1" ] || { echo "Argument name is not specified!" >&2 && exit 1; } local arg_name="${1:0:1}" # first character of argument name to find local is_flag="$2" || 0 # 1 if we need just find a flag, 0 to get a value local var_name="$3" || 0 # variable name to return value into or 0 to echo it in stdout local value= # initialize empty value to check if we found one later local arg_found=0 # marker of found argument for idx in "${!RAW_ARGS[@]}"; do # going through all args local arg_search=${RAW_ARGS[idx]} # get current argument # skip $arg_search if it starts with '--' or letter grep_match "$arg_search" "^(\w|--)" && continue # clear $arg_search from special and duplicate characters, e.g. 'fas-)dfs' will become 'fasd' local arg_chars="$(printf "%s" "$arg_search" \ | tr -s "[$arg_search]" 2>/dev/null \ | tr -d "[:punct:][:blank:]" 2>/dev/null)" # if $arg_name is not one of $arg_chars the skip it grep_match "-$arg_name" "^-[$arg_chars]$" || continue arg_found=1 # then return '1'|'0' back into $value if we need flag or next arg value otherwise [[ "$is_flag" == 1 ]] && value=1 || value="${RAW_ARGS[idx+1]}" break done [[ "$is_flag" == 1 ]] && [[ -z "$value" ]] && value=0; # if value we found is empty or looks like another argument then exit with error message if [ "$arg_found" = 1 ] && ! grep_match "$value" "^[[:graph:]]+$" || grep_match "$value" "^--?\w+$"; then echo "ERROR: Argument '-$arg_name' must have correct value!" >&2 && exit 1 fi # return '$value' back into $var_name (if exists) or echo in stdout [ "$var_name" ] && eval "$var_name='$value'" || echo "$value" } # Парсит длинный аргумент argl() { [ "$1" ] || { echo "Argument name is not specified!" >&2 && exit 1; } local arg_name="$1" # argument name to find local is_flag="$2" || 0 # 1 if we need just find a flag, 0 to get a value local var_name="$3" || 0 # variable name to return value into or 0 to echo it in stdout local value= # initialize empty value to check if we found one later local arg_found=0 # marker of found argument for idx in "${!RAW_ARGS[@]}"; do # going through all args local arg_search="${RAW_ARGS[idx]}" # get current argument if [ "$arg_search" = "--$arg_name" ]; then # if current arg begins with two dashes # then return '1' back into $value if we need flag or next arg value otherwise [[ "$is_flag" == 1 ]] && value=1 || value="${RAW_ARGS[idx+1]}" break # stop the loop elif grep_match "$arg_search" "^--$arg_name=.+$"; then # check if $arg like '--foo=bar' # then return '1' back into $value if we need flag or part from '=' to arg's end as value otherwise [[ "$is_flag" == 1 ]] && value=1 || value="${arg_search#*=}" break # stop the loop fi done [[ "$is_flag" == 1 ]] && [[ -z "$value" ]] && value=0; # if value we found is empty or looks like another argument then exit with error message if [ "$arg_found" = 1 ] && ! grep_match "$value" "^[[:graph:]]+$" || grep_match "$value" "^--?\w+$"; then echo "ERROR: Argument '--$arg_name' must have correct value!" >&2 && exit 1; fi # return '$value' back into $var_name (if exists) or echo in stdout [ "$var_name" ] && eval "$var_name='$value'" || echo "$value" } ######################################################## # Функции контроля системных пакетов ######################################################## # Проверяет наличие команды в системе installed() { command -v "$1" >/dev/null 2>&1 } # Проверяет наличие пакета в системе installed_pkg() { dpkg --list | grep -qw "ii $1" } # Требует наличие пакета в системе require() { sw=() for package in "$@"; do # if ! installed "$package" && ! installed_pkg "$package"; then if ! installed "$package"; then sw+=("$package") fi done if [ ${#sw[@]} -gt 0 ]; then die "Это ПО должно быть установлено в системе:\n${sw[*]}" 200 fi } ######################################################## # Функции для работы с git ######################################################## # Проверяет, является ли директория git-репозиторием git.is_repo() { require git is_dir "$1/" && is_dir "$1/.git/" } # Клонирует репозиторий git.clone() { require git cmd="git clone $*" debug "Команда: $cmd" $cmd } ######################################################## # Функции для работы с docker ######################################################## # Вызывает корректную команду docker compose docker.compose() { require docker args=${*/--profiles=[a-zA-Z_,0-9]*/} if docker compose &>/dev/null; then local cmd="docker compose $args" elif installed_pkg "docker-compose"; then local cmd="docker-compose $args" warn warn "docker-compose v1 устарел и не поддерживается, его поведение непредсказуемо." warn "Обнови docker согласно документации: https://docs.docker.com/engine/install/" warn else error "Должен быть установлен docker-compose-plugin!" die "Установи docker согласно документации: https://docs.docker.com/engine/install/" 2 fi debug "Команда: $cmd" $cmd } # Выводит информацию о контейнере docker.inspect() { cmd="docker inspect $*" debug "Команда: $cmd" $cmd 2>/dev/null } # Строит образы согласно файлов iptv-docker/docker-compose*.yml docker.build_base_images() { # for file in compose*.yml; do # subtitle "Построение базовых образов: $file" # docker.compose -f "$file" build # done subtitle "Построение образов" docker.compose build success "Базовые образы построены" } # Выполняет команду в контейнере от имени root docker.exec() { cmd="docker exec -u root -it $*" debug "Команда: $cmd" $cmd } # Выполняет команду в контейнере от имени www-data docker.exec_www() { cmd="docker exec -u www-data -it $*" debug "Команда: $cmd" $cmd } ######################################################## # Хелперы для обработки команд ######################################################## # Возвращает ssh-адрес к репозиторию проекта project_url_ssh() { echo "$IPTV_GITEA_URL_SSH/$1.git" } # Возвращает https-адрес к репозиторию проекта project_url_https() { echo "$IPTV_GITEA_URL_HTTPS/$1" } # Копирует .env.example в .env, если возможно prepare_dot_env() { if is_file .env.example && ! is_file .env; then debug "Копирование .env.example => .env" cp .env.example .env fi } # Клонирует проект project_clone() { local repo_url="$1"; shift local repo_path="$1"; shift if git.is_repo "$repo_path"; then debug "Репозиторий уже существует: $repo_path" else debug "Репозиторий будет склонирован: $repo_path" git.clone "$repo_url" "$repo_path" "$@" fi } # Проверяет передачу флагов -h|--help в команду и выводит справку process_help_arg() { command="${FUNCNAME[1]}" need_help=$(arg help 1) [[ "$need_help" -eq 0 ]] && need_help=$(argl help 1) [[ "$need_help" -eq 1 ]] && help "$command" } # Выводит список сервисов list_services_compose() { services=$(docker.compose --profile full config --services | sort) IFS=$'\n' sorted=("${services[@]}") unset IFS for name in "${sorted[@]}"; do print " $name" done } # Возвращает корректное название сервиса из композа find_service_compose() { svc="$1" [ -z "$svc" ] && die "неизвестный сервис" 2 for known in $(docker.compose config --services); do if [ "$known" = "$svc" ]; then debug "Сервис '$known' найден в композе" echo "$known" exit fi done debug "Сервис '$svc' не найден в композе" return 1 } # Возвращает корректные названия сервисов из композа find_services_compose() { local services='' [ "$*" ] && for svc in "$@"; do grep_match "$svc" "^--?.*" && continue svc="$(find_service_compose "$svc")" services="$services $svc" done trim "$services" } ######################################################## # Главные функции обработки команд ######################################################## # Инициализирует репозиторий iptv-docker и все вложенные в него init() { process_help_arg local docker_repo_path=$ROOT_PATH local basename=$(basename "$docker_repo_path") if [ ! "$basename" = "iptv-docker" ]; then # это наиболее надёжный способ понять откуда запускается скрипт docker_repo_path="$docker_repo_path/iptv-docker" fi #TODO установка ПО subtitle "Подготовка локальной среды..." project_clone "$IPTV_DOCKER_URL_SSH" "$docker_repo_path" cd "$docker_repo_path" prepare_dot_env local counter=1 local repo_count=${#IPTV_PROJECTS[@]} for repo_name in "${IPTV_PROJECTS[@]}"; do local project_repo_path="$docker_repo_path/$repo_name" subtitle "[$counter/$repo_count] Подготовка репозитория ${FBOLD}$repo_name${FRESET}..." local project_repo_url=$(project_url_ssh "$repo_name") debug "Известная ссылка на репозиторий: $project_repo_url" project_clone "$project_repo_url" "$project_repo_path" cd "$project_repo_path" prepare_dot_env cd - >/dev/null success "Репозиторий $repo_name готов" counter=$((counter+1)) done success "Локальная среда развёрнута: $docker_repo_path" cd "$docker_repo_path" docker.build_base_images && up success "Проверь корректность значений, указанных в файлах .env, и выполни ${FBOLD}./iptv rebuild${FRESET} при необходимости." } # Создаёт и запускает контейнеры up() { process_help_arg subtitle "Создание и запуск контейнеров" argl profiles 0 profiles local services='' [ "$*" ] && services="$(find_services_compose "$@")" COMPOSE_PROFILES="$profiles" docker.compose up "$services" --build --detach --remove-orphans && \ success 'Среда запущена успешно' } # Запускает созданные контейнеры start() { process_help_arg subtitle "Запуск созданных ранее контейнеров" profiles="$(argl profiles 0)" local services='' [ "$*" ] && services="$(find_services_compose "$@")" COMPOSE_PROFILES="$profiles" docker.compose start "$services" && \ success 'Среда запущена успешно' } # Останавливает и удаляет контейнеры down() { process_help_arg subtitle "Остановка и удаление контейнеров" argl profiles 0 profiles [[ -z "$profiles" ]] && profiles="full" local services='' [ "$*" ] && services="$(find_services_compose "$@")" COMPOSE_PROFILES="$profiles" docker.compose down "$services" --remove-orphans && \ success 'Среда остановлена успешно' } # Останавливает контейнеры stop() { process_help_arg subtitle "Остановка контейнеров" profiles=$(argl profiles 0) [[ -z "$profiles" ]] && profiles="full" local services='' [ "$*" ] && services="$(find_services_compose "$@")" COMPOSE_PROFILES="$profiles" docker.compose stop "$services" && \ success 'Среда остановлена успешно' } # Останавливает, удаляет, создаёт и запускает контейнеры rebuild() { process_help_arg is_full=$(arg full 1) [ "$is_full" = 0 ] && is_full=$(argl full 1) [ -n "$*" ] && down "$@" [ "$is_full" = 1 ] && docker.build_base_images up "$@" } # Останавливает и запускает созданные контейнеры restart() { process_help_arg stop "$@" && start "$@" } # Останавливает среду, удаляет все контейнеры и образы purge() { process_help_arg subtitle "Удаление docker-образов и контейнеров" down docker rmi "$(docker image list | grep iptv- | awk '{print $3}')" docker system prune --all --force success 'Образы удалены успешно' } # Выводит логи сервиса logs() { process_help_arg [ -z "$1" ] && die "не указан сервис" 8 svc=$(find_service_compose "$1") [ -z "$svc" ] && die "неизвестный сервис" 3 subtitle "Логи сервиса $svc" shift cmd="docker logs $svc $*" debug "Команда: $cmd" $cmd } # Выводит статистику потребления ресурсов контейнерами stats() { subtitle "Состояние контейнеров" docker.compose stats "$@" } ######################################################## # Команды справки ######################################################## help() { print "${FBOLD}Среда разработки iptv.axenov.dev" is_function "help.$1" && help."$1" && exit print "Использование:" print " ./iptv КОМАНДА [АРГУМЕНТЫ]" print print "Доступные КОМАНДЫ:" print " h|help - вывести это сообщение и выйти" print " init - инициализировать окружение" print " u|up - построить образы, контейнеры и запустить их" print " start - запустить построенные ранее контейнеры" print " d|down - остановить и удалить контейнеры" print " stop - остановить контейнеры" print " r|rebuild - выполнить 'down' и 'up'" print " restart - выполнить 'stop' и 'start'" print " purge - удалить контейнеры и образы" print " update - обновить репозитории" print " info - вывести версии ПО контейнера сервиса" print " f|flame - сгенерировать флеймграф из трейса xdebug" print " fix - исправить некоторые проблемы окружения" print " exec - выполнить произвольную команду в контейнере" print " logs - вывести логи контейнера" print " profiles - вывести список профилей" print " services - вывести список сервисов профиля" print " info - вывести информацию о ПО в контейнере" print " status - вывести статус сервиса и его репозитория" print " stats - вывести информацию о потреблении ресурсов окружением" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести справку по любой КОМАНДЕ" print print "Любая КОМАНДА может также иметь свои АРГУМЕНТЫ." print "Некоторые КОМАНДЫ могут также поддерживать АРГУМЕНТЫ 'docker' и его субсомманд." print print "См. './iptv help КОМАНДА' или './iptv КОМАНДА -h|--help' для справки." } help.help() { print "КОМАНДА: help" print "Отображает справку по скрипту и его командам." print print "Использование:" print " ./iptv help [КОМАНДА]" print " ./iptv КОМАНДА [-h|--help]" print print "Если КОМАНДА не указана, будет отображена справка по скрипту со списком допустимых КОМАНД." } help.init() { print "КОМАНДА: init" print "Инициализирует окружение разработки. Клонирует репозиторий iptv-docker, клонирует" print "внутрь исходники всех сервисов и создаёт заготовки файлов .env для них. Можно" print "использовать для подгрузки свежих сервисов, ещё не развёрнутых локально." print print "Использование:" print " ./iptv init [АРГУМЕНТЫ]" print print "Доступные АРГУМЕНТЫ:" print " -n|--name ИМЯ - установить ИМЯ в качестве имени пользователя git" print " -e|--email ПОЧТА - установить ПОЧТУ в качестве email пользователя git" print " -h|--help - вывести это сообщение и выйти" print print "Если --name и/или --email не переданы, то данные будут взяты из глобального конфига git." print print "ИМЯ и ПОЧТА проверяются на валидность:" print " - ИМЯ должно содержать имя и фамилию на кириллице, разделённые пробелом;" print " - ПОЧТА должна быть корпоративной (@bars.group или @bars-open.ru)." print print "Если ИМЯ или ПОЧТА некорректны, КОМАНДА завершится с ошибкой и предложением передать" print "явно ИМЯ и ПОЧТУ с помощью аргументов." print print "После выполнения КОМАНДЫ необходимо указать корректные данные в файлах .env сервисов." } help.up() { print "КОМАНДА: up" print "Выполняет перестроение образов (включая базовые, при необходимости)" print "создание и запуск контейнеров." print print "Использование:" print " ./iptv up [АРГУМЕНТЫ] [СЕРВИСЫ...]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Если указан один и более СЕРВИСОВ, то КОМАНДА будет применена только к ним." print print "СЕРВИСЫ можно передавать без префикса 'iptv-'" } help.start() { print "КОМАНДА: start" print "Выполняет запуск построенных ранее контейнеров." print print "Использование:" print " ./iptv start [АРГУМЕНТЫ] [СЕРВИСЫ...]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Если указан один и более СЕРВИСОВ, то КОМАНДА будет применена только к ним." print print "СЕРВИСЫ можно передавать без префикса 'iptv-'" } help.down() { print "КОМАНДА: down" print "Выполняет остановку и удаление запущенных контейнеров." print print "Использование:" print " ./iptv down [АРГУМЕНТЫ] [СЕРВИСЫ...]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Если указан один и более СЕРВИСОВ, то КОМАНДА будет применена только к ним." print print "СЕРВИСЫ можно передавать без префикса 'iptv-'" } help.stop() { print "КОМАНДА: stop" print "Выполняет остановку запущенных контейнеров." print print "Использование:" print " ./iptv stop [АРГУМЕНТЫ] [СЕРВИСЫ...]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Если указан один и более СЕРВИСОВ, то КОМАНДА будет применена только к ним." print print "СЕРВИСЫ можно передавать без префикса 'iptv-'" } help.rebuild() { print "КОМАНДА: rebuild" print "Выполняет 'down' и 'up'." print print "Использование:" print " ./iptv rebuild [АРГУМЕНТЫ] [СЕРВИСЫ...]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Если указан один и более СЕРВИСОВ, то КОМАНДА будет применена только к ним." print print "СЕРВИСЫ можно передавать без префикса 'iptv-'" } help.restart() { print "КОМАНДА: restart" print "Выполняет 'stop' и 'start'." print print "Использование:" print " ./iptv restart [АРГУМЕНТЫ] [СЕРВИСЫ...]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Если указан один и более СЕРВИСОВ, то КОМАНДА будет применена только к ним." print print "СЕРВИСЫ можно передавать без префикса 'iptv-'" } help.purge() { print "КОМАНДА: purge" print "Удаляет все образы и контейнеры 'iptv-*'." print print "Использование:" print " ./iptv purge [АРГУМЕНТЫ]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" } help.logs() { print "КОМАНДА: logs" print "Выводит логи указанного СЕРВИСА." print print "Использование:" print " ./iptv logs СЕРВИС [АРГУМЕНТЫ]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Поддерживаются АРГУМЕНТЫ 'docker logs'." print print "СЕРВИС можно передавать без префикса 'iptv-'" } help.stats() { print "КОМАНДА: stats" print "Выводит потребление ресурсов окружением разработки." print print "Использование:" print " ./iptv stats [АРГУМЕНТЫ]" print print "Доступные АРГУМЕНТЫ:" print " -h|--help - вывести это сообщение и выйти" print print "Поддерживаются АРГУМЕНТЫ 'docker compose stats'." print "Рекомендуется использовать вместе с '--no-stream'." } ######################################################## # Точка входа ######################################################## debug "Директория выполнения: $ROOT_PATH/$IPTV_DIR" case "$COMMAND" in h|help ) help "$1" ;; init ) init "$@" ;; u|up ) up "$@" ;; start ) start "$@" ;; d|down ) down "$@" ;; stop ) stop "$@" ;; r|rebuild ) rebuild "$@" ;; restart ) restart "$@" ;; purge ) purge ;; logs ) logs "$@" ;; stats ) stats "$@" ;; * ) die "неизвестная команда. Смотри './iptv help' для справки." ;; esac