| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941 |
- #!/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}" ]] || \
- die "Dependency network addresses 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
- 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"; exec su-exec sub2api /app/sub2api' \
- >/dev/null
- }
- 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}"
- 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 "$@"
|