Ctrl K
ComunidadGuías…

deploy/apple-container.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SUB2API_ENV_FILE:-${SCRIPT_DIR}/.env}"

STACK_LABEL_KEY="org.sub2api.stack"
STACK_LABEL_VALUE="apple-container"
NETWORK_NAME="sub2api-apple"
APP_CONTAINER="sub2api-apple"
POSTGRES_CONTAINER="sub2api-apple-postgres"
REDIS_CONTAINER="sub2api-apple-redis"
APP_VOLUME="sub2api-apple-data"
POSTGRES_VOLUME="sub2api-apple-postgres-data"
REDIS_VOLUME="sub2api-apple-redis-data"
PLATFORM="linux/arm64"

TEMP_DIR=""
LOCK_DIR="${TMPDIR:-/tmp}/sub2api-apple-container.lock"
LOCK_ACQUIRED=false

APP_IMAGE=""
POSTGRES_IMAGE=""
REDIS_IMAGE=""
BIND_HOST=""
HOST_PORT=""
ACCESS_HOST=""
POSTGRES_USER=""
POSTGRES_PASSWORD=""
POSTGRES_DB=""
REDIS_PASSWORD=""
TZ_VALUE=""
NETWORK_SUBNET=""
POSTGRES_ADDRESS=""
REDIS_ADDRESS=""
APP_ENV_FILE=""
POSTGRES_ENV_FILE=""
POSTGRES_PROBE_ENV_FILE=""
REDIS_ENV_FILE=""

info() {
    printf '[INFO] %s\n' "$*"
}

warn() {
    printf '[WARN] %s\n' "$*" >&2
}

die() {
    printf '[ERROR] %s\n' "$*" >&2
    exit 1
}

usage() {
    cat <<'EOF'
Usage: ./apple-container.sh <command> [options]

Commands:
  init                  Create .env and generate required secrets
  up [--recreate]       Create and start the complete Sub2API stack
  down                  Stop the stack and preserve all data
  restart               Restart the stack in dependency order
  status                Show container and workload health
  logs <service> [-f]   Show logs for app, postgres, or redis
  pull                  Pull all stack images for linux/arm64
  destroy [options]     Delete stack containers and network

Destroy options:
  --volumes             Also delete all persistent data volumes
  --yes                 Skip the confirmation prompt

Environment:
  SUB2API_ENV_FILE      Path to the deployment env file (default: deploy/.env)
EOF
}

cleanup() {
    local exit_code=$?

    if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
        rm -rf "${TEMP_DIR}"
    fi
    if [[ "${LOCK_ACQUIRED}" == true && -d "${LOCK_DIR}" ]]; then
        rm -f "${LOCK_DIR}/pid"
        rmdir "${LOCK_DIR}" 2>/dev/null || true
    fi

    exit "${exit_code}"
}

acquire_lock() {
    if ! mkdir "${LOCK_DIR}" 2>/dev/null; then
        local owner_pid=""
        if [[ -f "${LOCK_DIR}/pid" ]]; then
            owner_pid="$(<"${LOCK_DIR}/pid")"
        fi
        if [[ "${owner_pid}" =~ ^[0-9]+$ ]] && ! kill -0 "${owner_pid}" 2>/dev/null; then
            rm -rf "${LOCK_DIR}"
            mkdir "${LOCK_DIR}" || die "Failed to reclaim stale operation lock."
        else
            die "Another Sub2API Apple container operation is already running."
        fi
    fi
    printf '%s\n' "$$" >"${LOCK_DIR}/pid"
    LOCK_ACQUIRED=true
    trap cleanup EXIT
    trap 'exit 130' INT
    trap 'exit 143' TERM
    trap 'exit 129' HUP
}

require_command() {
    command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
}

require_container_version() {
    local version_output major minor

    require_command container
    require_command plutil
    version_output="$(container --version)"
    if [[ ! "${version_output}" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
        die "Unable to parse Apple container version: ${version_output}"
    fi

    major="${BASH_REMATCH[1]}"
    minor="${BASH_REMATCH[2]}"
    if (( major < 1 || (major == 1 && minor < 1) )); then
        die "Apple container 1.1.0 or newer is required; found ${version_output}."
    fi
}

system_is_running() {
    container system status >/dev/null 2>&1
}

start_system() {
    if ! system_is_running; then
        info "Starting Apple container services..."
        container system start --enable-kernel-install
    fi
}

list_resource_ids() {
    case "$1" in
        container) container list --all --quiet ;;
        network) container network list --quiet ;;
        volume) container volume list --quiet ;;
        *) die "Unknown resource type: $1" ;;
    esac
}

resource_exists() {
    local resource_type=$1
    local resource_name=$2
    local output line

    if ! output="$(list_resource_ids "${resource_type}")"; then
        die "Failed to list Apple container ${resource_type} resources."
    fi

    while IFS= read -r line; do
        if [[ "${line}" == "${resource_name}" ]]; then
            return 0
        fi
    done <<<"${output}"

    return 1
}

inspect_resource() {
    case "$1" in
        container) container inspect "$2" ;;
        network) container network inspect "$2" ;;
        volume) container volume inspect "$2" ;;
        *) die "Unknown resource type: $1" ;;
    esac
}

assert_resource_owned() {
    local resource_type=$1
    local resource_name=$2
    local inspection compact

    inspection="$(inspect_resource "${resource_type}" "${resource_name}" | \
        plutil -extract 0.configuration.labels json -o - -)" || \
        die "Failed to inspect ${resource_type} ${resource_name}."
    compact="$(printf '%s' "${inspection}" | tr -d '[:space:]')"
    if [[ "${compact}" != *"\"${STACK_LABEL_KEY}\":\"${STACK_LABEL_VALUE}\""* ]]; then
        die "Refusing to manage existing ${resource_type} '${resource_name}' because it is not owned by this stack."
    fi
}

preflight_stack_ownership() {
    local resource_name

    for resource_name in "${APP_CONTAINER}" "${REDIS_CONTAINER}" "${POSTGRES_CONTAINER}"; do
        if resource_exists container "${resource_name}"; then
            assert_resource_owned container "${resource_name}"
        fi
    done
    if resource_exists network "${NETWORK_NAME}"; then
        assert_resource_owned network "${NETWORK_NAME}"
    fi
    for resource_name in "${APP_VOLUME}" "${REDIS_VOLUME}" "${POSTGRES_VOLUME}"; do
        if resource_exists volume "${resource_name}"; then
            assert_resource_owned volume "${resource_name}"
        fi
    done
}

ensure_network() {
    local existing_subnet
    local -a network_args

    if resource_exists network "${NETWORK_NAME}"; then
        assert_resource_owned network "${NETWORK_NAME}"
        if [[ -n "${NETWORK_SUBNET}" ]]; then
            existing_subnet="$(inspect_resource network "${NETWORK_NAME}" | \
                plutil -extract 0.configuration.ipv4Subnet raw -o - -)" || \
                die "Unable to read the IPv4 subnet for ${NETWORK_NAME}."
            if [[ "${existing_subnet}" != "${NETWORK_SUBNET}" ]]; then
                die "Existing network '${NETWORK_NAME}' uses subnet '${existing_subnet}', but APPLE_CONTAINER_NETWORK_SUBNET is '${NETWORK_SUBNET}'. Run './apple-container.sh destroy --yes' to recreate the network while preserving volumes, then run 'up' again."
            fi
        fi
        return
    fi

    info "Creating network ${NETWORK_NAME}..."
    network_args=(--label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}")
    if [[ -n "${NETWORK_SUBNET}" ]]; then
        network_args+=(--subnet "${NETWORK_SUBNET}")
    fi
    container network create "${network_args[@]}" "${NETWORK_NAME}" >/dev/null
}

ensure_volume() {
    local volume_name=$1

    if resource_exists volume "${volume_name}"; then
        assert_resource_owned volume "${volume_name}"
        return
    fi

    info "Creating volume ${volume_name}..."
    container volume create \
        --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
        "${volume_name}" >/dev/null
}

ensure_image_available() {
    local image=$1

    if container image inspect "${image}" >/dev/null 2>&1; then
        return
    fi
    info "Pulling ${image}..."
    container image pull --platform "${PLATFORM}" "${image}"
}

container_is_running() {
    local container_name=$1
    local output line

    output="$(container list --quiet)" || die "Failed to list running Apple containers."
    while IFS= read -r line; do
        if [[ "${line}" == "${container_name}" ]]; then
            return 0
        fi
    done <<<"${output}"

    return 1
}

ensure_system() {
    require_container_version
    require_command curl
    start_system
}

container_ipv4_address() {
    local container_name=$1
    local address

    address="$(container inspect "${container_name}" | \
        plutil -extract 0.status.networks.0.ipv4Address raw -o - -)" || \
        die "Unable to read the network address for ${container_name}."
    address="${address%%/*}"
    [[ "${address}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
        die "Apple container returned an invalid IPv4 address for ${container_name}: ${address}"
    printf '%s\n' "${address}"
}

read_env_value() {
    local key=$1
    local fallback=${2-}

    awk -v wanted="${key}" -v fallback="${fallback}" '
        BEGIN { found = 0 }
        /^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
        {
            separator = index($0, "=")
            if (separator == 0) { next }
            key = substr($0, 1, separator - 1)
            if (key == wanted) {
                value = substr($0, separator + 1)
                sub(/\r$/, "", value)
                found = 1
            }
        }
        END {
            if (found) { print value }
            else { print fallback }
        }
    ' "${ENV_FILE}"
}

replace_env_value() {
    local key=$1
    local value=$2
    local target_file=${3:-${ENV_FILE}}
    local temp_file="${target_file}.tmp.$$"

    awk -v wanted="${key}" -v replacement="${value}" '
        BEGIN { replaced = 0 }
        {
            separator = index($0, "=")
            key = separator == 0 ? "" : substr($0, 1, separator - 1)
            if (key == wanted) {
                if (!replaced) { print wanted "=" replacement }
                replaced = 1
                next
            }
            print
        }
        END {
            if (!replaced) { print wanted "=" replacement }
        }
    ' "${target_file}" >"${temp_file}"
    chmod 600 "${temp_file}"
    mv "${temp_file}" "${target_file}"
}

generate_secret() {
    openssl rand -hex 32
}

cmd_init() {
    local env_dir temp_file postgres_secret jwt_secret totp_secret

    require_command openssl

    if [[ -e "${ENV_FILE}" ]]; then
        die "Environment file already exists: ${ENV_FILE}"
    fi

    postgres_secret="$(generate_secret)" || die "Failed to generate PostgreSQL password."
    jwt_secret="$(generate_secret)" || die "Failed to generate JWT secret."
    totp_secret="$(generate_secret)" || die "Failed to generate TOTP encryption key."
    [[ -n "${postgres_secret}" && -n "${jwt_secret}" && -n "${totp_secret}" ]] || \
        die "Secret generation returned an empty value."

    env_dir="$(dirname "${ENV_FILE}")"
    temp_file="${ENV_FILE}.init.tmp.$$"
    mkdir -p "${env_dir}"
    cp "${SCRIPT_DIR}/.env.example" "${temp_file}"
    chmod 600 "${temp_file}"
    replace_env_value POSTGRES_PASSWORD "${postgres_secret}" "${temp_file}"
    replace_env_value JWT_SECRET "${jwt_secret}" "${temp_file}"
    replace_env_value TOTP_ENCRYPTION_KEY "${totp_secret}" "${temp_file}"
    mv "${temp_file}" "${ENV_FILE}"

    info "Created ${ENV_FILE} with generated secrets."
    info "Review the file, then run: SUB2API_ENV_FILE='${ENV_FILE}' ${SCRIPT_DIR}/apple-container.sh up"
}

validate_port() {
    local port=$1
    local decimal_port

    [[ "${port}" =~ ^[0-9]+$ ]] || die "SERVER_PORT must be numeric: ${port}"
    decimal_port=$((10#${port}))
    (( decimal_port >= 1025 && decimal_port <= 65535 )) || \
        die "SERVER_PORT must be between 1025 and 65535 for Apple container port forwarding."
}

validate_ipv4_address() {
    local address=$1
    local first second third fourth extra octet

    IFS=. read -r first second third fourth extra <<<"${address}"
    [[ -n "${first}" && -n "${second}" && -n "${third}" && -n "${fourth}" && -z "${extra}" ]] || \
        die "BIND_HOST must be a valid IPv4 address: ${address}"
    for octet in "${first}" "${second}" "${third}" "${fourth}"; do
        [[ "${octet}" =~ ^[0-9]+$ ]] || die "BIND_HOST must be a valid IPv4 address: ${address}"
        (( 10#${octet} <= 255 )) || die "BIND_HOST must be a valid IPv4 address: ${address}"
    done
}

validate_env_file_security() {
    local owner mode permissions

    [[ -f "${ENV_FILE}" ]] || die "Environment file not found: ${ENV_FILE}. Run '$0 init' first."
    owner="$(stat -f '%u' "${ENV_FILE}")" || die "Unable to read owner for ${ENV_FILE}."
    mode="$(stat -f '%Lp' "${ENV_FILE}")" || die "Unable to read permissions for ${ENV_FILE}."
    [[ "${owner}" == "${EUID}" ]] || die "Environment file must be owned by the current user: ${ENV_FILE}"
    [[ "${mode}" =~ ^[0-7]+$ ]] || die "Unable to parse permissions for ${ENV_FILE}: ${mode}"
    permissions=$((8#${mode}))
    (( (permissions & 077) == 0 )) || \
        die "Environment file must not be readable by group or others. Run: chmod 600 '${ENV_FILE}'"
}

prepare_environment() {
    validate_env_file_security

    APP_IMAGE="$(read_env_value APPLE_CONTAINER_SUB2API_IMAGE weishaw/sub2api:latest)"
    POSTGRES_IMAGE="$(read_env_value APPLE_CONTAINER_POSTGRES_IMAGE postgres:18-alpine)"
    REDIS_IMAGE="$(read_env_value APPLE_CONTAINER_REDIS_IMAGE redis:8-alpine)"
    BIND_HOST="$(read_env_value BIND_HOST 0.0.0.0)"
    HOST_PORT="$(read_env_value SERVER_PORT 8080)"
    POSTGRES_USER="$(read_env_value POSTGRES_USER sub2api)"
    POSTGRES_PASSWORD="$(read_env_value POSTGRES_PASSWORD)"
    POSTGRES_DB="$(read_env_value POSTGRES_DB sub2api)"
    REDIS_PASSWORD="$(read_env_value REDIS_PASSWORD)"
    TZ_VALUE="$(read_env_value TZ Asia/Shanghai)"
    NETWORK_SUBNET="$(read_env_value APPLE_CONTAINER_NETWORK_SUBNET)"

    [[ -n "${BIND_HOST}" ]] || die "BIND_HOST must not be empty."
    validate_ipv4_address "${BIND_HOST}"
    validate_port "${HOST_PORT}"
    if [[ "${BIND_HOST}" == "0.0.0.0" ]]; then
        ACCESS_HOST="127.0.0.1"
    else
        ACCESS_HOST="${BIND_HOST}"
    fi
    [[ -n "${POSTGRES_USER}" ]] || die "POSTGRES_USER must not be empty."
    [[ -n "${POSTGRES_DB}" ]] || die "POSTGRES_DB must not be empty."
    if [[ -z "${POSTGRES_PASSWORD}" || "${POSTGRES_PASSWORD}" == "change_this_secure_password" ]]; then
        die "Set a secure POSTGRES_PASSWORD in ${ENV_FILE}."
    fi

    TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/sub2api-apple.XXXXXX")"
    APP_ENV_FILE="${TEMP_DIR}/app.env"
    POSTGRES_ENV_FILE="${TEMP_DIR}/postgres.env"
    POSTGRES_PROBE_ENV_FILE="${TEMP_DIR}/postgres-probe.env"
    REDIS_ENV_FILE="${TEMP_DIR}/redis.env"

    cat >"${POSTGRES_ENV_FILE}" <<EOF
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=${POSTGRES_DB}
TZ=${TZ_VALUE}
EOF

    cat >"${POSTGRES_PROBE_ENV_FILE}" <<EOF
PGPASSWORD=${POSTGRES_PASSWORD}
EOF

    cat >"${REDIS_ENV_FILE}" <<EOF
REDIS_PASSWORD=${REDIS_PASSWORD}
TZ=${TZ_VALUE}
EOF
    if [[ -n "${REDIS_PASSWORD}" ]]; then
        printf 'REDISCLI_AUTH=%s\n' "${REDIS_PASSWORD}" >>"${REDIS_ENV_FILE}"
    fi

    chmod 600 "${POSTGRES_ENV_FILE}" "${POSTGRES_PROBE_ENV_FILE}" "${REDIS_ENV_FILE}"
}

prepare_app_environment() {
    [[ -n "${POSTGRES_ADDRESS}" && -n "${REDIS_ADDRESS}" && -n "${APP_IMAGE_ID}" ]] || \
        die "Dependency network addresses or application image ID are not available."

    cp "${ENV_FILE}" "${APP_ENV_FILE}"
    cat >>"${APP_ENV_FILE}" <<EOF

AUTO_SETUP=true
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
DATABASE_HOST=${POSTGRES_ADDRESS}
DATABASE_PORT=5432
DATABASE_USER=${POSTGRES_USER}
DATABASE_PASSWORD=${POSTGRES_PASSWORD}
DATABASE_DBNAME=${POSTGRES_DB}
DATABASE_SSLMODE=disable
REDIS_HOST=${REDIS_ADDRESS}
REDIS_PORT=6379
REDIS_PASSWORD=${REDIS_PASSWORD}
DATA_DIR=/app/storage/data
APPLE_CONTAINER_SUB2API_IMAGE_ID=${APP_IMAGE_ID}
EOF
    chmod 600 "${APP_ENV_FILE}"
}

create_postgres_container() {
    info "Creating PostgreSQL container..."
    container create \
        --name "${POSTGRES_CONTAINER}" \
        --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
        --network "${NETWORK_NAME}" \
        --platform "${PLATFORM}" \
        --ulimit nofile=100000:100000 \
        --env-file "${POSTGRES_ENV_FILE}" \
        --volume "${POSTGRES_VOLUME}:/var/lib/postgresql" \
        "${POSTGRES_IMAGE}" >/dev/null
}

create_redis_container() {
    info "Creating Redis container..."
    container create \
        --name "${REDIS_CONTAINER}" \
        --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
        --network "${NETWORK_NAME}" \
        --platform "${PLATFORM}" \
        --ulimit nofile=100000:100000 \
        --env-file "${REDIS_ENV_FILE}" \
        --volume "${REDIS_VOLUME}:/var/lib/redis" \
        "${REDIS_IMAGE}" \
        sh -c 'set -e; mkdir -p /var/lib/redis/data; chown redis:redis /var/lib/redis/data; exec /usr/local/bin/docker-entrypoint.sh redis-server --dir /var/lib/redis/data --save 60 1 --appendonly yes --appendfsync everysec ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' \
        >/dev/null
}

create_app_container() {
    info "Creating Sub2API container..."
    container create \
        --name "${APP_CONTAINER}" \
        --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
        --network "${NETWORK_NAME}" \
        --platform "${PLATFORM}" \
        --ulimit nofile=100000:100000 \
        --publish "${BIND_HOST}:${HOST_PORT}:8080/tcp" \
        --env-file "${APP_ENV_FILE}" \
        --volume "${APP_VOLUME}:/app/storage" \
        --entrypoint /bin/sh \
        "${APP_IMAGE}" \
        -c '
set -e
mkdir -p "$DATA_DIR"
chown -R sub2api:sub2api "$DATA_DIR"

runtime_dir=/app/storage/runtime
runtime_binary="$runtime_dir/sub2api"
image_marker="$runtime_dir/base-image-id"
installed_image_id=""
if [ -f "$image_marker" ]; then
    installed_image_id="$(cat "$image_marker")"
fi
if [ ! -x "$runtime_binary" ] || [ "$installed_image_id" != "$APPLE_CONTAINER_SUB2API_IMAGE_ID" ]; then
    mkdir -p "$runtime_dir"
    cp /app/sub2api "${runtime_binary}.new"
    chown sub2api:sub2api "${runtime_binary}.new"
    chmod 0755 "${runtime_binary}.new"
    mv "${runtime_binary}.new" "$runtime_binary"
    printf "%s\n" "$APPLE_CONTAINER_SUB2API_IMAGE_ID" >"${image_marker}.new"
    mv "${image_marker}.new" "$image_marker"
    rm -f "${runtime_binary}.backup"
fi
chown -R sub2api:sub2api "$runtime_dir"

child_pid=""
stop() {
    trap - TERM INT
    if [ -n "$child_pid" ]; then
        kill -TERM "$child_pid" 2>/dev/null || true
        wait "$child_pid" 2>/dev/null || true
    fi
    exit 0
}
trap stop TERM INT

while true; do
    su-exec sub2api "$runtime_binary" &
    child_pid=$!
    set +e
    wait "$child_pid"
    status=$?
    set -e
    child_pid=""
    echo "Sub2API exited with status ${status}; restarting in 1 second..." >&2
    sleep 1
done
' \
        >/dev/null
}

application_image_id() {
    container image inspect "${APP_IMAGE}" | \
        plutil -extract 0.id raw -o - -
}

ensure_container() {
    local container_name=$1
    local create_function=$2

    if resource_exists container "${container_name}"; then
        assert_resource_owned container "${container_name}"
        return
    fi

    "${create_function}"
}

start_container_if_needed() {
    local container_name=$1

    if container_is_running "${container_name}"; then
        return
    fi

    info "Starting ${container_name}..."
    container start "${container_name}" >/dev/null
}

stop_container_if_running() {
    local container_name=$1

    if ! resource_exists container "${container_name}"; then
        return
    fi
    assert_resource_owned container "${container_name}"
    if container_is_running "${container_name}"; then
        info "Stopping ${container_name}..."
        container stop --time 30 "${container_name}" >/dev/null
    fi
}

delete_container_if_present() {
    local container_name=$1

    if ! resource_exists container "${container_name}"; then
        return
    fi
    assert_resource_owned container "${container_name}"
    if container_is_running "${container_name}"; then
        container stop --time 30 "${container_name}" >/dev/null
    fi
    info "Deleting ${container_name}..."
    container delete "${container_name}" >/dev/null
}

wait_for_probe() {
    local description=$1
    local attempts=$2
    shift 2

    local attempt
    for ((attempt = 1; attempt <= attempts; attempt++)); do
        if "$@" >/dev/null 2>&1; then
            info "${description} is ready."
            return 0
        fi
        sleep 1
    done

    return 1
}

probe_postgres() {
    container exec --env-file "${POSTGRES_PROBE_ENV_FILE}" \
        "${POSTGRES_CONTAINER}" \
        psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
        -v ON_ERROR_STOP=1 -tAc 'SELECT 1'
}

probe_redis() {
    container exec --env-file "${REDIS_ENV_FILE}" \
        "${REDIS_CONTAINER}" \
        redis-cli ping
}

probe_app() {
    container exec "${APP_CONTAINER}" \
        wget -q -T 5 -O /dev/null http://localhost:8080/health
}

probe_host_app() {
    curl --fail --silent --show-error --max-time 5 \
        "http://${ACCESS_HOST}:${HOST_PORT}/health"
}

show_failure_logs() {
    local container_name=$1

    warn "Last logs from ${container_name}:"
    container logs -n 50 "${container_name}" >&2 || true
}

start_dependencies() {
    start_container_if_needed "${POSTGRES_CONTAINER}"
    if ! wait_for_probe "PostgreSQL" 90 probe_postgres; then
        show_failure_logs "${POSTGRES_CONTAINER}"
        die "PostgreSQL did not become ready."
    fi

    start_container_if_needed "${REDIS_CONTAINER}"
    if ! wait_for_probe "Redis" 60 probe_redis; then
        show_failure_logs "${REDIS_CONTAINER}"
        die "Redis did not become ready."
    fi
}

start_app() {
    start_container_if_needed "${APP_CONTAINER}"
    if ! wait_for_probe "Sub2API" 180 probe_app; then
        show_failure_logs "${APP_CONTAINER}"
        die "Sub2API did not become ready."
    fi
    if ! wait_for_probe "Sub2API host port" 15 probe_host_app; then
        die "Host port forwarding failed. In System Settings > Privacy & Security > Local Network, allow container-runtime-linux; restart Apple container services; then run 'apple-container.sh up' again."
    fi
}

cmd_up() {
    local recreate=false

    if [[ $# -gt 1 || ($# -eq 1 && "${1-}" != "--recreate") ]]; then
        usage
        exit 2
    fi
    if [[ $# -eq 1 ]]; then
        recreate=true
    fi

    ensure_system
    prepare_environment
    preflight_stack_ownership
    ensure_network
    ensure_volume "${APP_VOLUME}"
    ensure_volume "${POSTGRES_VOLUME}"
    ensure_volume "${REDIS_VOLUME}"
    ensure_image_available "${APP_IMAGE}"
    ensure_image_available "${POSTGRES_IMAGE}"
    ensure_image_available "${REDIS_IMAGE}"
    APP_IMAGE_ID="$(application_image_id)" || \
        die "Unable to inspect application image ID for ${APP_IMAGE}."
    [[ -n "${APP_IMAGE_ID}" ]] || die "Application image ID must not be empty."

    if [[ "${recreate}" == true ]]; then
        delete_container_if_present "${APP_CONTAINER}"
        delete_container_if_present "${REDIS_CONTAINER}"
        delete_container_if_present "${POSTGRES_CONTAINER}"
    fi

    ensure_container "${POSTGRES_CONTAINER}" create_postgres_container
    ensure_container "${REDIS_CONTAINER}" create_redis_container
    start_dependencies
    POSTGRES_ADDRESS="$(container_ipv4_address "${POSTGRES_CONTAINER}")"
    REDIS_ADDRESS="$(container_ipv4_address "${REDIS_CONTAINER}")"
    prepare_app_environment
    # The dependency IPs may change whenever their lightweight VMs restart.
    delete_container_if_present "${APP_CONTAINER}"
    create_app_container
    start_app

    info "Sub2API is available at http://${ACCESS_HOST}:${HOST_PORT}"
}

cmd_down() {
    require_container_version
    if ! system_is_running; then
        info "Apple container services are already stopped."
        return
    fi
    preflight_stack_ownership
    stop_container_if_running "${APP_CONTAINER}"
    stop_container_if_running "${REDIS_CONTAINER}"
    stop_container_if_running "${POSTGRES_CONTAINER}"
    info "Sub2API stack stopped; persistent volumes were preserved."
}

cmd_restart() {
    cmd_down
    cmd_up
}

print_container_status() {
    local service=$1
    local container_name=$2

    if ! resource_exists container "${container_name}"; then
        printf '%-12s %s\n' "${service}" "missing"
    elif container_is_running "${container_name}"; then
        printf '%-12s %s\n' "${service}" "running"
    else
        printf '%-12s %s\n' "${service}" "stopped"
    fi
}

cmd_status() {
    local failed=0

    require_container_version
    if ! system_is_running; then
        printf '%-12s %s\n' "system" "stopped"
        return 1
    fi

    printf '%-12s %s\n' "system" "running"
    preflight_stack_ownership
    print_container_status app "${APP_CONTAINER}"
    print_container_status postgres "${POSTGRES_CONTAINER}"
    print_container_status redis "${REDIS_CONTAINER}"

    if [[ -f "${ENV_FILE}" ]]; then
        prepare_environment
        if container_is_running "${POSTGRES_CONTAINER}" && probe_postgres >/dev/null 2>&1; then
            printf '%-12s %s\n' "postgres" "healthy"
        else
            printf '%-12s %s\n' "postgres" "unhealthy"
            failed=1
        fi
        if container_is_running "${REDIS_CONTAINER}" && probe_redis >/dev/null 2>&1; then
            printf '%-12s %s\n' "redis" "healthy"
        else
            printf '%-12s %s\n' "redis" "unhealthy"
            failed=1
        fi
        if container_is_running "${APP_CONTAINER}" && probe_app >/dev/null 2>&1; then
            printf '%-12s %s\n' "app" "healthy"
        else
            printf '%-12s %s\n' "app" "unhealthy"
            failed=1
        fi
        if container_is_running "${APP_CONTAINER}" && probe_host_app >/dev/null 2>&1; then
            printf '%-12s %s\n' "host-port" "healthy"
        else
            printf '%-12s %s\n' "host-port" "unhealthy"
            failed=1
        fi
    else
        warn "Health probes require ${ENV_FILE}."
        failed=1
    fi

    return "${failed}"
}

cmd_logs() {
    local service=${1-}
    local follow=${2-}
    local container_name

    [[ $# -ge 1 && $# -le 2 ]] || { usage; exit 2; }
    if [[ -n "${follow}" && "${follow}" != "-f" && "${follow}" != "--follow" ]]; then
        usage
        exit 2
    fi

    case "${service}" in
        app|sub2api) container_name="${APP_CONTAINER}" ;;
        postgres) container_name="${POSTGRES_CONTAINER}" ;;
        redis) container_name="${REDIS_CONTAINER}" ;;
        *) die "Unknown service '${service}'. Use app, postgres, or redis." ;;
    esac

    require_container_version
    system_is_running || die "Apple container services are not running."
    resource_exists container "${container_name}" || die "Container not found: ${container_name}"
    assert_resource_owned container "${container_name}"
    if [[ -n "${follow}" ]]; then
        container logs --follow "${container_name}"
    else
        container logs "${container_name}"
    fi
}

cmd_pull() {
    ensure_system
    prepare_environment
    info "Pulling ${APP_IMAGE}..."
    container image pull --platform "${PLATFORM}" "${APP_IMAGE}"
    info "Pulling ${POSTGRES_IMAGE}..."
    container image pull --platform "${PLATFORM}" "${POSTGRES_IMAGE}"
    info "Pulling ${REDIS_IMAGE}..."
    container image pull --platform "${PLATFORM}" "${REDIS_IMAGE}"
}

confirm_destroy() {
    local include_volumes=$1
    local answer

    if [[ "${include_volumes}" == true ]]; then
        printf 'Delete the Sub2API stack and all persistent data? [y/N] '
    else
        printf 'Delete the Sub2API containers and network, preserving volumes? [y/N] '
    fi
    read -r answer
    [[ "${answer}" == "y" || "${answer}" == "Y" ]]
}

delete_volume_if_present() {
    local volume_name=$1

    if resource_exists volume "${volume_name}"; then
        assert_resource_owned volume "${volume_name}"
        info "Deleting volume ${volume_name}..."
        container volume delete "${volume_name}" >/dev/null
    fi
}

cmd_destroy() {
    local include_volumes=false
    local assume_yes=false
    local argument

    for argument in "$@"; do
        case "${argument}" in
            --volumes) include_volumes=true ;;
            --yes) assume_yes=true ;;
            *) usage; exit 2 ;;
        esac
    done

    require_container_version
    start_system
    preflight_stack_ownership
    if [[ "${assume_yes}" != true ]] && ! confirm_destroy "${include_volumes}"; then
        info "Cancelled."
        return
    fi

    delete_container_if_present "${APP_CONTAINER}"
    delete_container_if_present "${REDIS_CONTAINER}"
    delete_container_if_present "${POSTGRES_CONTAINER}"

    if resource_exists network "${NETWORK_NAME}"; then
        assert_resource_owned network "${NETWORK_NAME}"
        info "Deleting network ${NETWORK_NAME}..."
        container network delete "${NETWORK_NAME}" >/dev/null
    fi

    if [[ "${include_volumes}" == true ]]; then
        delete_volume_if_present "${APP_VOLUME}"
        delete_volume_if_present "${REDIS_VOLUME}"
        delete_volume_if_present "${POSTGRES_VOLUME}"
        info "Sub2API stack and persistent data deleted."
    else
        info "Sub2API stack deleted; persistent volumes were preserved."
    fi
}

main() {
    local command=${1-}
    if [[ $# -gt 0 ]]; then
        shift
    fi

    case "${command}" in
        init)
            [[ $# -eq 0 ]] || { usage; exit 2; }
            acquire_lock
            cmd_init
            ;;
        up)
            acquire_lock
            cmd_up "$@"
            ;;
        down)
            [[ $# -eq 0 ]] || { usage; exit 2; }
            acquire_lock
            cmd_down
            ;;
        restart)
            [[ $# -eq 0 ]] || { usage; exit 2; }
            acquire_lock
            cmd_restart
            ;;
        status)
            [[ $# -eq 0 ]] || { usage; exit 2; }
            trap cleanup EXIT
            cmd_status
            ;;
        logs)
            cmd_logs "$@"
            ;;
        pull)
            [[ $# -eq 0 ]] || { usage; exit 2; }
            acquire_lock
            cmd_pull
            ;;
        destroy)
            acquire_lock
            cmd_destroy "$@"
            ;;
        help|-h|--help)
            usage
            ;;
        *)
            usage
            exit 2
            ;;
    esac
}

main "$@"