#!/bin/bash
# dsv - manage runit services on Das OS, the way systemctl does elsewhere.
#
# A service is a directory /etc/sv/<name>; it is enabled (started at boot) by
# linking it into the default runlevel, which runsvdir watches through
# /var/service. Logs come from the service's own svlogd directory or, for
# services that log to syslog, from socklog.
set -euo pipefail

# shellcheck source=helpers.sh
. "${DAS_HELPERS:-/usr/lib/das/helpers.sh}"

sv_dir=/etc/sv
runlevel=/etc/runit/runsvdir/default
# Outside a running system (a chroot, an image) /var/service may not exist
service_dir=/var/service
[[ -d $service_dir ]] || service_dir=$runlevel
socklog=/var/log/socklog

usage() {
  if [[ -n $uk ]]; then
    cat <<'EOF'
Використання: dsv КОМАНДА [СЛУЖБА...]

  list                    усі служби: чи ввімкнена, стан, PID, час роботи
  status СЛУЖБА...        стан служби (sv status)
  enable СЛУЖБА...        запускати під час завантаження й запустити зараз
  disable СЛУЖБА...       не запускати під час завантаження й зупинити
  start|stop|restart СЛУЖБА...
                          запустити, зупинити, перезапустити
  logs СЛУЖБА [-f]        журнал служби (-f: стежити за новими рядками)
  new [СЛУЖБА] [ПАРАМЕТРИ]
                          створити службу для своєї програми; чого не
                          вказано, те запитає:
      --cmd КОМАНДА       що запускати (програма не має йти у фон)
      --user КОРИСТУВАЧ   від чийого імені
      --dir КАТАЛОГ       робочий каталог
      --env ІМ'Я=ЗНАЧЕННЯ змінна середовища, можна кілька разів
      --no-log            без власного журналу в /var/log/СЛУЖБА
      --enable            одразу ввімкнути й запустити
  rm СЛУЖБА               вимкнути й видалити службу, створену через new

Служби — каталоги в /etc/sv. Без команди виконується list.
EOF
  else
    cat <<'EOF'
Usage: dsv COMMAND [SERVICE...]

  list                    all services: enabled or not, state, PID, uptime
  status SERVICE...       service state (sv status)
  enable SERVICE...       start at boot and start now
  disable SERVICE...      do not start at boot and stop now
  start|stop|restart SERVICE...
                          start, stop, restart
  logs SERVICE [-f]       the service log (-f: follow new lines)
  new [SERVICE] [OPTIONS] create a service for your own program; asks for
                          what is not given:
      --cmd COMMAND       what to run (the program must stay in the foreground)
      --user USER         who runs it
      --dir DIR           working directory
      --env NAME=VALUE    an environment variable, may be repeated
      --no-log            no log of its own in /var/log/SERVICE
      --enable            enable and start it right away
  rm SERVICE              disable and delete a service made with new

Services are the directories in /etc/sv. Without a command, runs list.
EOF
  fi
}

check_service() {
  [[ -d $sv_dir/$1 ]] || die "немає служби '$1' (див. dsv list)" "no service '$1' (see dsv list)"
}

is_enabled() {
  [[ -e $runlevel/$1 || -L $runlevel/$1 ]]
}

# Waits up to 10 s for runsvdir to pick up a new link (it rescans every 5 s)
wait_supervised() {
  local i
  for ((i = 0; i < 20; i++)); do
    [[ -e $service_dir/$1/supervise/ok ]] && return 0
    sleep 0.5
  done
  return 1
}

# row NAME ENABLED STATE PID UPTIME: one line of the list
row() {
  pad "$1" 24
  pad "$2" 9
  pad "$3" 6
  pad "$4" 8
  printf '%s\n' "$5"
}

cmd_list() {
  local dir name enabled state pid time line
  row "$(say СЛУЖБА SERVICE)" "$(say УВІМКН. ENABLED)" "$(say СТАН STATE)" PID "$(say ПРАЦЮЄ UPTIME)"
  for dir in "$sv_dir"/*/; do
    name=$(basename "$dir")
    enabled=$(say ні no)
    state=-
    pid=-
    time=-
    if is_enabled "$name"; then
      enabled=$(say так yes)
      # "run: /var/service/sshd: (pid 812) 3600s; run: log: ..."
      line=$(sv status "$service_dir/$name" 2>/dev/null || true)
      state=${line%%:*}
      [[ $line =~ \(pid\ ([0-9]+)\) ]] && pid=${BASH_REMATCH[1]}
      [[ $line =~ \ ([0-9]+)s ]] && time=$(human_time "${BASH_REMATCH[1]}")
      [[ -n $state ]] || state='?'
    fi
    row "$name" "$enabled" "$state" "$pid" "$time"
  done
}

# human_time SECONDS: 3d4h, 2h5m, 7m, 42s
human_time() {
  local s=$1
  if ((s >= 86400)); then
    echo "$((s / 86400))d$((s % 86400 / 3600))h"
  elif ((s >= 3600)); then
    echo "$((s / 3600))h$((s % 3600 / 60))m"
  elif ((s >= 60)); then
    echo "$((s / 60))m"
  else
    echo "${s}s"
  fi
}

cmd_status() {
  local name rc=0
  for name in "$@"; do
    check_service "$name"
    if is_enabled "$name"; then
      sv status "$service_dir/$name" || rc=1
    else
      say "$name: вимкнена (dsv enable $name)" "$name: disabled (dsv enable $name)"
    fi
  done
  return $rc
}

cmd_enable() {
  local name
  for name in "$@"; do
    check_service "$name"
    if is_enabled "$name"; then
      say "$name: уже ввімкнена" "$name: already enabled"
      continue
    fi
    ln -s "$sv_dir/$name" "$runlevel/$name"
    say "$name: увімкнена" "$name: enabled"
    # runsvdir starts it within 5 s. Right after "dsv disable" its runsv is
    # still there with the service stopped, so ask for "up" as well.
    if [[ $service_dir == /var/service ]] && wait_supervised "$name"; then
      sv -v up "$service_dir/$name" || true
    fi
  done
}

cmd_disable() {
  local name
  for name in "$@"; do
    check_service "$name"
    if ! is_enabled "$name"; then
      say "$name: уже вимкнена" "$name: already disabled"
      continue
    fi
    # Stop it first: runsv only notices the removed link at runsvdir's next scan
    [[ $service_dir == /var/service ]] && sv down "$service_dir/$name" 2>/dev/null || true
    rm "$runlevel/$name"
    say "$name: вимкнена" "$name: disabled"
  done
}

cmd_control() {
  local action=$1 name rc=0
  shift
  for name in "$@"; do
    check_service "$name"
    if ! is_enabled "$name"; then
      say "$name: вимкнена, спершу: dsv enable $name" "$name: disabled, first: dsv enable $name" >&2
      rc=1
      continue
    fi
    # -v waits for the new state (up to 7 s) and prints it
    sv -v "$action" "$service_dir/$name" || rc=1
  done
  return $rc
}

# The log directory of a service whose log/run starts svlogd, if any
svlogd_dir() {
  local run=$sv_dir/$1/log/run words w
  [[ -r $run ]] || return 1
  grep -q svlogd "$run" || return 1
  read -ra words < <(grep -m1 svlogd "$run")
  for w in "${words[@]}"; do
    [[ $w == /* && -d $w ]] && echo "$w" && return 0
  done
  return 1
}

cmd_logs() {
  local name=${1:-} follow="" dir
  [[ -n $name ]] || die "вкажіть службу: dsv logs СЛУЖБА [-f]" "name a service: dsv logs SERVICE [-f]"
  [[ ${2:-} == -f ]] && follow=1
  check_service "$name"

  if dir=$(svlogd_dir "$name"); then
    if [[ -n $follow ]]; then
      exec tail -n 50 -F "$dir/current"
    fi
    cat "$dir"/@*.[su] "$dir/current" 2>/dev/null | tail -n 200
    return
  fi

  # Services without their own log write to syslog (vlogger -t <name>, or the
  # daemon itself as "<name>[pid]:" or "<name>-<helper>[pid]:", like
  # sshd-session), which socklog sorts into directories by facility.
  if ! compgen -G "$socklog/*/current" >/dev/null; then
    die "у $name немає власного журналу, а syslog не записується; увімкніть socklog: dsv enable socklog-unix nanoklogd" \
      "$name has no log of its own and syslog is not recorded; enable socklog: dsv enable socklog-unix nanoklogd"
  fi
  local pattern="[[:space:]]${name}(-[a-z]+)?(\[[0-9]+\])?:"
  if [[ -n $follow ]]; then
    tail -q -n 0 -F "$socklog"/*/current | grep --line-buffered -E "$pattern"
  else
    # A message can be in several directories (e.g. "everything" and "secure")
    cat "$socklog"/*/@*.[su] "$socklog"/*/current 2>/dev/null | grep -E "$pattern" | sort -u | tail -n 200 ||
      say "$name: у syslog немає записів (служба може писати лише в консоль runit)" \
        "$name: no syslog entries (the service may only write to the runit console)"
  fi
}

# check_running NAME: after a start, tells whether the service stays up or
# keeps restarting (runsv restarts a program that exits, once a second)
check_running() {
  local line up='^run:[^;]*\) ([0-9]+)s'
  sleep 3
  line=$(sv status "$service_dir/$1" 2>/dev/null || true)
  if [[ $line =~ $up ]] && ((BASH_REMATCH[1] >= 2)); then
    say "$1: працює" "$1: running"
  else
    warn "$1 не працює стабільно (${line%%;*}); див. dsv logs $1" \
      "$1 does not stay up (${line%%;*}); see dsv logs $1"
  fi
}

cmd_new() {
  local name="" run_cmd="" user="" dir="" log=1 enable="" envs=() e home bin
  while (($#)); do
    case $1 in
      --cmd) run_cmd=${2:?}; shift ;;
      --user) user=${2:?}; shift ;;
      --dir) dir=${2:?}; shift ;;
      --env) envs+=("${2:?}"); shift ;;
      --no-log) log="" ;;
      --enable) enable=1 ;;
      -*) die "невідомий параметр $1 (dsv help)" "unknown option $1 (dsv help)" ;;
      *) [[ -z $name ]] || die "зайвий аргумент $1" "extra argument $1"; name=$1 ;;
    esac
    shift
  done

  if [[ -z $name ]]; then
    interactive || die "вкажіть назву служби" "name the service"
    ask name "Назва служби" "Service name"
  fi
  [[ $name =~ ^[A-Za-z0-9][A-Za-z0-9_.@-]*$ ]] ||
    die "назва '$name': лише латинські літери, цифри, '.', '_', '-', '@'" \
      "name '$name': only Latin letters, digits, '.', '_', '-', '@'"
  [[ ! -e $sv_dir/$name ]] || die "служба $name уже є" "service $name already exists"

  if [[ -z $run_cmd ]]; then
    interactive || die "вкажіть команду: --cmd" "give the command: --cmd"
    say "Команда запуску. Програма має працювати на передньому плані (без -d/--daemon)." \
      "The command to run. The program must stay in the foreground (no -d/--daemon)."
    ask run_cmd "Команда" "Command"
    [[ -n $run_cmd ]] || die "команду не вказано" "no command given"
  fi
  read -r bin _ <<<"$run_cmd"
  if [[ $bin == */* ]]; then
    [[ -x $bin ]] || die "$bin не існує або не виконуваний" "$bin does not exist or is not executable"
  elif ! command -v "$bin" >/dev/null; then
    die "команду $bin не знайдено; вкажіть повний шлях" "command $bin not found; give the full path"
  fi

  if [[ -z $user ]] && interactive; then
    ask user "Від імені користувача" "Run as user" "${SUDO_USER:-root}"
  fi
  user=${user:-root}
  id -u "$user" >/dev/null 2>&1 || die "немає користувача $user" "no user $user"
  home=$(getent passwd "$user" | cut -d: -f6)

  if [[ -z $dir ]] && interactive; then
    ask dir "Робочий каталог" "Working directory" "$home"
  fi
  dir=${dir:-$home}
  [[ -d $dir ]] || die "немає каталогу $dir" "no directory $dir"

  if ((${#envs[@]} == 0)) && interactive; then
    ask e "Змінні середовища ІМ'Я=ЗНАЧЕННЯ через пробіл (Enter — без них)" \
      "Environment variables NAME=VALUE separated by spaces (Enter for none)"
    read -ra envs <<<"$e"
  fi
  for e in "${envs[@]}"; do
    [[ $e =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || die "'$e' не має вигляду ІМ'Я=ЗНАЧЕННЯ" "'$e' is not NAME=VALUE"
  done

  # Build it aside, then move it in place in one step
  local tmp=$sv_dir/.$name.new
  rm -rf "$tmp"
  mkdir -p "$tmp/env"
  # chpst does not set these for the new user
  printf '%s\n' "$home" >"$tmp/env/HOME"
  printf '%s\n' "$user" >"$tmp/env/USER"
  for e in "${envs[@]}"; do
    printf '%s\n' "${e#*=}" >"$tmp/env/${e%%=*}"
  done
  cat >"$tmp/run" <<EOF
#!/bin/sh
# Created by dsv new. After editing: dsv restart $name
# Environment variables: one file per variable in $sv_dir/$name/env
exec 2>&1
cd '$dir' || exit 1
exec chpst -u $user -e $sv_dir/$name/env $run_cmd
EOF
  chmod 755 "$tmp/run"
  if [[ -n $log ]]; then
    mkdir -p "$tmp/log" "/var/log/$name"
    cat >"$tmp/log/run" <<EOF
#!/bin/sh
# Log of $name, read with: dsv logs $name
mkdir -p /var/log/$name
exec svlogd -tt /var/log/$name
EOF
    chmod 755 "$tmp/log/run"
  fi
  mv "$tmp" "$sv_dir/$name"
  say "$name: створена в $sv_dir/$name" "$name: created in $sv_dir/$name"

  if [[ -z $enable ]] && interactive; then
    confirm "Увімкнути й запустити зараз?" "Enable and start it now?" && enable=1
  fi
  if [[ -n $enable ]]; then
    cmd_enable "$name"
    [[ $service_dir == /var/service ]] && check_running "$name"
  else
    say "Запустити: dsv enable $name" "To start it: dsv enable $name"
  fi
}

cmd_rm() {
  local name=${1:-} i
  [[ -n $name ]] || die "вкажіть службу" "name a service"
  check_service "$name"
  # Services from packages come back with the next update; disable them instead
  # (xbps-query -o succeeds even when no package owns the file)
  if [[ -n $(xbps-query -o "$sv_dir/$name/run" 2>/dev/null) ]]; then
    die "$name встановлена пакетом, її можна лише вимкнути: dsv disable $name" \
      "$name comes from a package, it can only be disabled: dsv disable $name"
  fi
  if interactive && ! confirm "Видалити службу $name?" "Delete service $name?"; then
    return 1
  fi
  if is_enabled "$name"; then
    cmd_disable "$name"
    # runsvdir stops its runsv at the next scan; wait for it before deleting
    for ((i = 0; i < 20; i++)); do
      pgrep -x -f "runsv $name" >/dev/null || break
      sleep 0.5
    done
  fi
  rm -rf "${sv_dir:?}/$name"
  say "$name: видалена" "$name: deleted"
  [[ -d /var/log/$name ]] && say "Журнал залишився в /var/log/$name" "The log is kept in /var/log/$name"
  return 0
}

cmd=${1:-list}
[[ $# -gt 0 ]] && shift

case $cmd in
  list | ls)
    need_root list "$@"
    cmd_list
    ;;
  status)
    [[ $# -gt 0 ]] || die "вкажіть службу" "name a service"
    need_root status "$@"
    cmd_status "$@"
    ;;
  enable | disable | start | stop | restart)
    [[ $# -gt 0 ]] || die "вкажіть службу" "name a service"
    need_root "$cmd" "$@"
    case $cmd in
      enable) cmd_enable "$@" ;;
      disable) cmd_disable "$@" ;;
      start) cmd_control up "$@" ;;
      stop) cmd_control down "$@" ;;
      restart) cmd_control restart "$@" ;;
    esac
    ;;
  logs | log)
    # socklog's logs are readable by root and the socklog group only
    [[ -r $socklog/messages/current ]] || need_root logs "$@"
    cmd_logs "$@"
    ;;
  new | create)
    need_root new "$@"
    cmd_new "$@"
    ;;
  rm | remove | delete)
    need_root rm "$@"
    cmd_rm "$@"
    ;;
  -h | --help | help)
    usage
    ;;
  *)
    usage >&2
    exit 1
    ;;
esac
