#!/QOpenSys/usr/bin/sh
# zendinit -- command line interface for zend-initd on IBM i (PASE).
#
# POSIX sh only; see lib/common.sh for the house rules.  The shebang and the
# library path below are rewritten by `make install`.
set -u
LC_ALL=C
export LC_ALL

# `make install` substitutes both placeholders.  The checks further down test
# the *shape* of each value -- an absolute path, a leading digit -- rather than
# comparing against the placeholder text, which would otherwise have to be
# spelled so that it survives the very sed that rewrites these lines.
ZI_VERSION='0.1.2'
ZI_LIBDIR_INSTALLED='/QOpenSys/pkgs/libexec/zend-initd/lib'

# ---------------------------------------------------------------------------
# Bootstrap
# ---------------------------------------------------------------------------
# ${1+"$@"} rather than "$@", throughout.  With set -u and no positional
# parameters, PASE ksh treats "$@" as an unset parameter and dies with
# "@: parameter not set" -- and the supervisor is invoked with no arguments at
# all by STRZINITD, so this would have made IPL autostart fail silently while
# every manual test passed.  bash, dash and ksh93 do not do this, so nothing
# off-platform reproduces it.
#
# On IBM i, /bin/sh is the QSH shell (/QSYS.LIB/QSHELL.LIB/QZSHSH.PGM), not
# PASE.  A source-tree copy still carrying a #!/bin/sh shebang therefore runs
# under an interpreter that does not handle PASE signals: the supervisor was
# seen to die on SIGTERM with "PASE for i ended for signal 15" instead of
# running its stop handler, leaving its services behind.
#
# `make install` rewrites the shebang, so an installed copy is trusted and left
# alone -- an operator who chose a different SHEBANG gets what they chose.  An
# unsubstituted placeholder means a checkout being run in place, and that is
# re-executed under the PASE shell exactly once.
zi_pase_reexec() {
    case $ZI_LIBDIR_INSTALLED in
        /*) return 0 ;;
    esac
    [ -z "${ZI_PASE_REEXEC:-}" ] || return 0
    [ -x /QOpenSys/usr/bin/sh ] || return 0
    ZI_PASE_REEXEC=1
    export ZI_PASE_REEXEC
    exec /QOpenSys/usr/bin/sh "$0" ${1+"$@"}
}

zi_self_dir() {
    case $0 in
        */*) ZI_SELF_DIR=${0%/*} ;;
        *)   ZI_SELF_DIR=. ;;
    esac
}

zi_bootstrap() {
    zi_pase_reexec ${1+"$@"}
    zi_self_dir
    if [ -n "${ZENDINIT_LIBDIR:-}" ]; then
        ZI_LIBDIR=$ZENDINIT_LIBDIR
    else
        ZI_LIBDIR=
        # Substituted it is an absolute path; unsubstituted it is not, which
        # means we are running from a source tree.
        case $ZI_LIBDIR_INSTALLED in
            /*) [ -d "$ZI_LIBDIR_INSTALLED" ] && ZI_LIBDIR=$ZI_LIBDIR_INSTALLED ;;
        esac
        if [ -z "$ZI_LIBDIR" ]; then
            for zi_cand in "$ZI_SELF_DIR/../lib" "$ZI_SELF_DIR/lib"; do
                if [ -f "$zi_cand/config.sh" ]; then
                    ZI_LIBDIR=$zi_cand
                    break
                fi
            done
        fi
    fi
    if [ -z "$ZI_LIBDIR" ] || [ ! -f "$ZI_LIBDIR/config.sh" ]; then
        printf 'zendinit: cannot locate the zend-initd library directory\n' >&2
        printf 'zendinit: set ZENDINIT_LIBDIR to the directory holding config.sh\n' >&2
        exit 1
    fi

    . "$ZI_LIBDIR/common.sh"
    . "$ZI_LIBDIR/paths.sh"
    . "$ZI_LIBDIR/log.sh"
    . "$ZI_LIBDIR/config.sh"
    . "$ZI_LIBDIR/adapter.sh"
    . "$ZI_LIBDIR/state.sh"
    . "$ZI_LIBDIR/lock.sh"
    . "$ZI_LIBDIR/service.sh"
    . "$ZI_LIBDIR/commands.sh"

    # A substituted version starts with a digit; the placeholder does not.
    case $ZI_VERSION in
        [0-9]*) ;;
        *)
            if [ -r "$ZI_SELF_DIR/../VERSION" ]; then
                ZI_VERSION=$(cat "$ZI_SELF_DIR/../VERSION")
            else
                ZI_VERSION=unknown
            fi
            ZI_VERSION="$ZI_VERSION (source tree)"
            ;;
    esac
}

# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------

zi_usage() {
    cat <<'EOF'
Usage: zendinit [options] <command> [arguments]

Service commands:
  list                   every configured service, its state and its job
  status <service>       one service in detail
  start <service>        start it, and keep it started
  stop <service>         stop it, and keep it stopped across restarts
  restart <service>      stop then start
  enable <service>       set enabled=yes in its configuration
  disable <service>      set enabled=no, which also stops it
  logs <service>         its output log
  joblog <service>       the IBM i joblog for its job

System commands:
  system status          supervisor, adapter and service summary
  system start           start the subsystem and the supervisor
  system stop            stop every service, then the supervisor
  system autostart install|remove|status
                         whether the supervisor starts at IPL, via an
                         autostart job entry on QSYSWRK
  system profile create|status
                         the supervisor's own least-privilege user profile,
                         which the autostart job runs as
  reload                 have the supervisor re-read its configuration
  validate [target...]   check configuration without changing anything
  help, version

start, stop, restart, enable and disable accept --all instead of a service.

Options:
  --json                 machine-readable output (list, status)
  --strict               validate: treat warnings as failures
  -q, --quiet            suppress per-file "ok" lines from validate
      --global           validate: treat file targets as global config files
      --no-wait          do not wait for the requested state to be reached
      --timeout N        how long to wait for it (default 90 seconds; start
                         and stop wait longer for a service whose
                         start_timeout or stop_timeout needs it)
  -n, --lines N          logs: how many lines (default 50)
  -f, --follow           logs: keep following
  -h, --help             show this help
  -V, --version          show the version

Exit status:
  0  success                     1  failure
  2  bad invocation              3  service not running
  4  service status unknown

An administrator's start and stop are remembered, so a service stopped by hand
stays stopped until it is started again -- including across a supervisor
restart and across an IPL.
EOF
}

# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------

ZI_TOT_ERR=0
ZI_TOT_WARN=0
ZI_FILES=0
ZI_JOBMAP=
ZI_DEPMAP=
ZI_JSON_FILES=

# Print a newline-separated list as JSON array members.
zi_json_lines() {
    zi_jl_rest=$1
    zi_jl_first=yes
    while [ -n "$zi_jl_rest" ]; do
        cmn_pop_line "$zi_jl_rest"
        zi_jl_item=$CMN_R
        zi_jl_rest=$CMN_REST
        cmn_json_escape "$zi_jl_item"
        if [ "$zi_jl_first" = yes ]; then
            zi_jl_first=no
        else
            printf ','
        fi
        printf '"%s"' "$CMN_R"
    done
}

# Emit the diagnostics collected for one target and fold them into the totals.
# $1=path $2=kind $3=note
zi_report_target() {
    zi_rt_path=$1
    zi_rt_kind=$2
    zi_rt_note=$3
    zi_rt_err=$CFG_ERROR_COUNT
    zi_rt_warn=$CFG_WARN_COUNT

    ZI_FILES=$((ZI_FILES + 1))
    ZI_TOT_ERR=$((ZI_TOT_ERR + zi_rt_err))
    ZI_TOT_WARN=$((ZI_TOT_WARN + zi_rt_warn))

    if [ "$zi_rt_err" -gt 0 ]; then
        zi_rt_status=fail
    elif [ "$zi_rt_warn" -gt 0 ]; then
        zi_rt_status=warn
    else
        zi_rt_status=ok
    fi

    if [ "$ZI_JSON" = yes ]; then
        cmn_json_escape "$zi_rt_path"
        zi_rt_json="{\"path\":\"$CMN_R\",\"kind\":\"$zi_rt_kind\",\"status\":\"$zi_rt_status\""
        cmn_json_escape "$zi_rt_note"
        zi_rt_json="$zi_rt_json,\"note\":\"$CMN_R\",\"errors\":["
        zi_rt_json="$zi_rt_json$(zi_json_lines "$CFG_ERRORS")],\"warnings\":["
        zi_rt_json="$zi_rt_json$(zi_json_lines "$CFG_WARNINGS")]}"
        if [ -n "$ZI_JSON_FILES" ]; then
            ZI_JSON_FILES="$ZI_JSON_FILES,$zi_rt_json"
        else
            ZI_JSON_FILES=$zi_rt_json
        fi
        return 0
    fi

    case $zi_rt_status in
        ok)
            if [ "$ZI_QUIET" != yes ]; then
                if [ -n "$zi_rt_note" ]; then
                    printf 'ok    %s (%s)\n' "$zi_rt_path" "$zi_rt_note"
                else
                    printf 'ok    %s\n' "$zi_rt_path"
                fi
            fi
            ;;
        warn) printf 'warn  %s\n' "$zi_rt_path" ;;
        fail) printf 'FAIL  %s\n' "$zi_rt_path" ;;
    esac
    zi_rt_rest=$CFG_ERRORS
    while [ -n "$zi_rt_rest" ]; do
        cmn_pop_line "$zi_rt_rest"
        printf '      %s\n' "$CMN_R"
        zi_rt_rest=$CMN_REST
    done
    zi_rt_rest=$CFG_WARNINGS
    while [ -n "$zi_rt_rest" ]; do
        cmn_pop_line "$zi_rt_rest"
        printf '      warning: %s\n' "$CMN_R"
        zi_rt_rest=$CMN_REST
    done
    return 0
}

zi_validate_global() {
    config_reset_diagnostics
    config_load_global "$1"
    if [ -e "$1" ]; then
        zi_report_target "$1" global ''
    elif [ "$ZI_JSON" != yes ] && [ "$ZI_QUIET" != yes ]; then
        printf 'ok    %s (absent, using defaults)\n' "$1"
    fi
}

zi_validate_service_file() {
    config_reset_diagnostics
    # Cleared up front: a file rejected before its defaults are applied must not
    # inherit the previous service's identity into the cross-check maps.
    SVC_NAME=
    SVC_JOBNAME=
    SVC_DEPENDS=
    SVC_ENABLED=no
    config_load_service "$1"
    zi_vsf_note=
    [ "${SVC_ENABLED:-no}" = no ] && zi_vsf_note=disabled
    zi_report_target "$1" service "$zi_vsf_note"

    # Remember what the cross-checks need; a service that failed to parse still
    # contributes what it did set, which keeps one bad file from hiding others.
    if [ -n "${SVC_NAME:-}" ] && [ -n "${SVC_JOBNAME:-}" ]; then
        zi_vsf_kind=derived
        config_derive_jobname "$SVC_NAME"
        [ "$CMN_R" = "$SVC_JOBNAME" ] || zi_vsf_kind=explicit
        ZI_JOBMAP="$ZI_JOBMAP$SVC_NAME:$SVC_JOBNAME:$zi_vsf_kind
"
        zi_vsf_rest=$SVC_DEPENDS
        while [ -n "$zi_vsf_rest" ]; do
            cmn_pop_line "$zi_vsf_rest"
            ZI_DEPMAP="$ZI_DEPMAP$SVC_NAME:$CMN_R
"
            zi_vsf_rest=$CMN_REST
        done
    fi
}

# Checks that only make sense across the whole set.
zi_cross_checks() {
    config_reset_diagnostics
    config_list_services
    zi_cc_all=$CMN_R

    zi_cc_rest=$ZI_DEPMAP
    while [ -n "$zi_cc_rest" ]; do
        cmn_pop_line "$zi_cc_rest"
        zi_cc_line=$CMN_R
        zi_cc_rest=$CMN_REST
        zi_cc_svc=${zi_cc_line%%:*}
        zi_cc_dep=${zi_cc_line#*:}
        zi_cc_found=no
        zi_cc_r2=$zi_cc_all
        while [ -n "$zi_cc_r2" ]; do
            cmn_pop_line "$zi_cc_r2"
            [ "$CMN_R" = "$zi_cc_dep" ] && zi_cc_found=yes
            zi_cc_r2=$CMN_REST
            [ "$zi_cc_found" = yes ] && break
        done
        if [ "$zi_cc_found" = no ]; then
            paths_service_conf "$zi_cc_svc"
            config_error "$CMN_R" 0 "depends on unknown service: $zi_cc_dep"
        fi
    done

    [ -n "$ZI_JOBMAP" ] && config_decollide_jobnames "$ZI_JOBMAP"

    ZI_TOT_ERR=$((ZI_TOT_ERR + CFG_ERROR_COUNT))
    ZI_TOT_WARN=$((ZI_TOT_WARN + CFG_WARN_COUNT))

    if [ "$ZI_JSON" = yes ]; then
        ZI_CROSS_JSON="{\"errors\":[$(zi_json_lines "$CFG_ERRORS")],\"warnings\":[$(zi_json_lines "$CFG_WARNINGS")]}"
        return 0
    fi
    zi_cc_rest=$CFG_ERRORS
    while [ -n "$zi_cc_rest" ]; do
        cmn_pop_line "$zi_cc_rest"
        printf 'FAIL  %s\n' "$CMN_R"
        zi_cc_rest=$CMN_REST
    done
    zi_cc_rest=$CFG_WARNINGS
    while [ -n "$zi_cc_rest" ]; do
        cmn_pop_line "$zi_cc_rest"
        printf 'warn  %s\n' "$CMN_R"
        zi_cc_rest=$CMN_REST
    done
    return 0
}

# Resolve a user-supplied target to "global <path>" or "service <path>".
zi_resolve_target() {
    zi_res_in=$1
    if [ "$ZI_FORCE_GLOBAL" = yes ]; then
        CMN_R="global:$zi_res_in"
        return 0
    fi
    case $zi_res_in in
        */initd.conf|initd.conf)
            CMN_R="global:$zi_res_in"
            ;;
        */*|*.conf)
            CMN_R="service:$zi_res_in"
            ;;
        *)
            paths_service_conf "$zi_res_in"
            CMN_R="service:$CMN_R"
            ;;
    esac
    return 0
}

cmd_validate() {
    ZI_CROSS_JSON='{"errors":[],"warnings":[]}'

    if [ -z "$ZI_POS_REST" ]; then
        # Whole configuration.
        zi_validate_global "$ZI_GLOBAL_CONF"
        if [ ! -d "$ZI_SERVICES_DIR" ]; then
            if [ "$ZI_JSON" != yes ]; then
                printf 'warn  %s: service directory does not exist\n' "$ZI_SERVICES_DIR"
            fi
            ZI_TOT_WARN=$((ZI_TOT_WARN + 1))
        else
            config_list_services
            zi_cv_rest=$CMN_R
            if [ -z "$zi_cv_rest" ] && [ "$ZI_JSON" != yes ] && [ "$ZI_QUIET" != yes ]; then
                printf 'note  no services configured in %s\n' "$ZI_SERVICES_DIR"
            fi
            while [ -n "$zi_cv_rest" ]; do
                cmn_pop_line "$zi_cv_rest"
                zi_cv_name=$CMN_R
                zi_cv_rest=$CMN_REST
                paths_service_conf "$zi_cv_name"
                zi_validate_service_file "$CMN_R"
            done
        fi
    else
        # Explicit targets.  The global config is still loaded first, because
        # service defaults (jobq, jobd, notify) come from it.
        config_reset_diagnostics
        config_load_global "$ZI_GLOBAL_CONF"
        zi_cv_rest=$ZI_POS_REST
        while [ -n "$zi_cv_rest" ]; do
            cmn_pop_line "$zi_cv_rest"
            zi_cv_target=$CMN_R
            zi_cv_rest=$CMN_REST
            zi_resolve_target "$zi_cv_target"
            zi_cv_kind=${CMN_R%%:*}
            zi_cv_path=${CMN_R#*:}
            case $zi_cv_kind in
                global)
                    if [ ! -e "$zi_cv_path" ]; then
                        config_reset_diagnostics
                        config_error "$zi_cv_path" 0 "no such file"
                        zi_report_target "$zi_cv_path" global ''
                    else
                        zi_validate_global "$zi_cv_path"
                    fi
                    ;;
                service) zi_validate_service_file "$zi_cv_path" ;;
            esac
        done
    fi

    zi_cross_checks

    if [ "$ZI_JSON" = yes ]; then
        printf '{"files":[%s],"cross_checks":%s,' "$ZI_JSON_FILES" "$ZI_CROSS_JSON"
        printf '"summary":{"files":%d,"errors":%d,"warnings":%d}}\n' \
            "$ZI_FILES" "$ZI_TOT_ERR" "$ZI_TOT_WARN"
    elif [ "$ZI_QUIET" != yes ] || [ "$ZI_TOT_ERR" -gt 0 ]; then
        printf '%d file(s) checked, %d error(s), %d warning(s)\n' \
            "$ZI_FILES" "$ZI_TOT_ERR" "$ZI_TOT_WARN"
    fi

    [ "$ZI_TOT_ERR" -gt 0 ] && return "$EX_ERR"
    if [ "$ZI_STRICT" = yes ] && [ "$ZI_TOT_WARN" -gt 0 ]; then
        return "$EX_ERR"
    fi
    return "$EX_OK"
}

# ---------------------------------------------------------------------------
# Argument handling
# ---------------------------------------------------------------------------

ZI_JSON=no
ZI_QUIET=no
ZI_STRICT=no
ZI_FORCE_GLOBAL=no
# ZI_NOWAIT, ZI_FOLLOW, ZI_TIMEOUT and ZI_LINES are read by lib/commands.sh,
# which shellcheck lints separately and so cannot see.
# shellcheck disable=SC2034
ZI_ALL=no
ZI_NOWAIT=no
ZI_FOLLOW=no
ZI_TIMEOUT=90
# yes once --timeout was given; start and stop otherwise size the wait from the
# service's own timeouts.
ZI_TIMEOUT_SET=no
ZI_LINES=50
ZI_POS=

zi_add_pos() {
    if [ -n "$ZI_POS" ]; then
        ZI_POS="$ZI_POS
$1"
    else
        ZI_POS=$1
    fi
}

zi_bootstrap ${1+"$@"}

while [ $# -gt 0 ]; do
    # A directive has to sit in front of a complete command, not in front of an
    # individual case branch.  Several of the options set here are read by
    # lib/commands.sh, which shellcheck lints separately.
    # shellcheck disable=SC2034
    case $1 in
        --json)    ZI_JSON=yes ;;
        --strict)  ZI_STRICT=yes ;;
        -q|--quiet) ZI_QUIET=yes ;;
        --global)  ZI_FORCE_GLOBAL=yes ;;
        --all)     ZI_ALL=yes ;;
        --no-wait) ZI_NOWAIT=yes ;;
        -f|--follow) ZI_FOLLOW=yes ;;
        --timeout) ZI_TIMEOUT=${2:-90}; ZI_TIMEOUT_SET=yes; shift ;;
        --timeout=*) ZI_TIMEOUT=${1#--timeout=}; ZI_TIMEOUT_SET=yes ;;
        -n|--lines) ZI_LINES=${2:-50}; shift ;;
        --lines=*) ZI_LINES=${1#--lines=} ;;
        -h|--help) zi_usage; exit "$EX_OK" ;;
        -V|--version) printf 'zendinit %s\n' "$ZI_VERSION"; exit "$EX_OK" ;;
        --)
            shift
            while [ $# -gt 0 ]; do
                zi_add_pos "$1"
                shift
            done
            break
            ;;
        -*)
            log_error "unknown option: $1"
            printf '\n' >&2
            zi_usage >&2
            exit "$EX_USAGE"
            ;;
        *) zi_add_pos "$1" ;;
    esac
    [ $# -gt 0 ] && shift
done

for zi_num in "$ZI_TIMEOUT" "$ZI_LINES"; do
    case $zi_num in
        ''|*[!0-9]*)
            log_error "--timeout and --lines take a number of seconds/lines: $zi_num"
            exit "$EX_USAGE"
            ;;
    esac
done

if [ -z "$ZI_POS" ]; then
    zi_usage >&2
    exit "$EX_USAGE"
fi

cmn_pop_line "$ZI_POS"
ZI_CMD=$CMN_R
ZI_POS_REST=$CMN_REST

# Every command except help and version needs the global settings.  validate
# loads them again for itself, because it also reports on them.
case $ZI_CMD in
    help|version) ;;
    *)
        config_reset_diagnostics
        config_load_global "$ZI_GLOBAL_CONF"
        if [ "$CFG_ERROR_COUNT" -gt 0 ] && [ "$ZI_CMD" != validate ]; then
            log_error "the global configuration has $CFG_ERROR_COUNT error(s); run 'zendinit validate' to see them"
            exit "$EX_ERR"
        fi
        log_set_level "$CFG_LOG_LEVEL"
        ;;
esac

# Everything except validate and help needs the platform adapter, because it
# has to ask what is actually running.
case $ZI_CMD in
    validate|help|version) ;;
    *)
        if ! adapter_load "$ZI_LIBDIR"; then
            log_error "no usable platform adapter"
            exit "$EX_ERR"
        fi
        # shellcheck disable=SC2034  # read by lib/ibmi.sh
        IBMI_LIBRARY=$CFG_LIBRARY
        # At top level, not inside a function: PASE ksh discards a trap
        # installed by a function when that function returns.  Without this the
        # adapter's work directory leaked once per invocation, which matters
        # here rather than in the supervisor -- the CLI is what monitoring runs
        # every minute.
        trap 'ibmi_cleanup' EXIT
        ;;
esac

# A service argument, or --all.
zi_target_or_all() {
    if [ "$ZI_ALL" = yes ]; then
        cmd_for_each "$1"
        exit $?
    fi
    if [ -z "$ZI_POS_REST" ]; then
        log_error "$ZI_CMD needs a service name, or --all"
        exit "$EX_USAGE"
    fi
    cmn_pop_line "$ZI_POS_REST"
    "$1" "$CMN_R"
    exit $?
}

case $ZI_CMD in
    validate) cmd_validate; exit $? ;;
    help)     zi_usage; exit "$EX_OK" ;;
    version)  printf 'zendinit %s\n' "$ZI_VERSION"; exit "$EX_OK" ;;

    list)    cmd_list; exit $? ;;
    status)
        if [ -z "$ZI_POS_REST" ]; then
            log_error "status needs a service name"
            exit "$EX_USAGE"
        fi
        cmn_pop_line "$ZI_POS_REST"
        cmd_status "$CMN_R"
        exit $?
        ;;
    start)   zi_target_or_all cmd_start ;;
    stop)    zi_target_or_all cmd_stop ;;
    restart) zi_target_or_all cmd_restart ;;
    enable)
        if [ "$ZI_ALL" = yes ]; then
            log_error "enable does not take --all: enabling every service at once is never what is meant"
            exit "$EX_USAGE"
        fi
        if [ -z "$ZI_POS_REST" ]; then
            log_error "enable needs a service name"
            exit "$EX_USAGE"
        fi
        cmn_pop_line "$ZI_POS_REST"
        cmd_set_enabled "$CMN_R" yes
        exit $?
        ;;
    disable)
        if [ -z "$ZI_POS_REST" ]; then
            log_error "disable needs a service name"
            exit "$EX_USAGE"
        fi
        cmn_pop_line "$ZI_POS_REST"
        cmd_set_enabled "$CMN_R" no
        exit $?
        ;;
    reload)  cmd_reload; exit $? ;;
    logs)
        if [ -z "$ZI_POS_REST" ]; then
            log_error "logs needs a service name"
            exit "$EX_USAGE"
        fi
        cmn_pop_line "$ZI_POS_REST"
        cmd_logs "$CMN_R"
        exit $?
        ;;
    joblog)
        if [ -z "$ZI_POS_REST" ]; then
            log_error "joblog needs a service name"
            exit "$EX_USAGE"
        fi
        cmn_pop_line "$ZI_POS_REST"
        cmd_joblog "$CMN_R"
        exit $?
        ;;
    system)
        if [ -z "$ZI_POS_REST" ]; then
            cmd_system status
        else
            cmn_pop_line "$ZI_POS_REST"
            zi_sub=$CMN_R
            cmn_pop_line "$CMN_REST"
            zi_sub2=$CMN_R
            cmn_pop_line "$CMN_REST"
            cmd_system "$zi_sub" "$zi_sub2" "$CMN_R"
        fi
        exit $?
        ;;

    *)
        log_error "unknown command: $ZI_CMD"
        printf '\n' >&2
        zi_usage >&2
        exit "$EX_USAGE"
        ;;
esac
