apple-container.sh 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  4. ENV_FILE="${SUB2API_ENV_FILE:-${SCRIPT_DIR}/.env}"
  5. STACK_LABEL_KEY="org.sub2api.stack"
  6. STACK_LABEL_VALUE="apple-container"
  7. NETWORK_NAME="sub2api-apple"
  8. APP_CONTAINER="sub2api-apple"
  9. POSTGRES_CONTAINER="sub2api-apple-postgres"
  10. REDIS_CONTAINER="sub2api-apple-redis"
  11. APP_VOLUME="sub2api-apple-data"
  12. POSTGRES_VOLUME="sub2api-apple-postgres-data"
  13. REDIS_VOLUME="sub2api-apple-redis-data"
  14. PLATFORM="linux/arm64"
  15. TEMP_DIR=""
  16. LOCK_DIR="${TMPDIR:-/tmp}/sub2api-apple-container.lock"
  17. LOCK_ACQUIRED=false
  18. APP_IMAGE=""
  19. POSTGRES_IMAGE=""
  20. REDIS_IMAGE=""
  21. BIND_HOST=""
  22. HOST_PORT=""
  23. ACCESS_HOST=""
  24. POSTGRES_USER=""
  25. POSTGRES_PASSWORD=""
  26. POSTGRES_DB=""
  27. REDIS_PASSWORD=""
  28. TZ_VALUE=""
  29. NETWORK_SUBNET=""
  30. POSTGRES_ADDRESS=""
  31. REDIS_ADDRESS=""
  32. APP_ENV_FILE=""
  33. POSTGRES_ENV_FILE=""
  34. POSTGRES_PROBE_ENV_FILE=""
  35. REDIS_ENV_FILE=""
  36. info() {
  37. printf '[INFO] %s\n' "$*"
  38. }
  39. warn() {
  40. printf '[WARN] %s\n' "$*" >&2
  41. }
  42. die() {
  43. printf '[ERROR] %s\n' "$*" >&2
  44. exit 1
  45. }
  46. usage() {
  47. cat <<'EOF'
  48. Usage: ./apple-container.sh <command> [options]
  49. Commands:
  50. init Create .env and generate required secrets
  51. up [--recreate] Create and start the complete Sub2API stack
  52. down Stop the stack and preserve all data
  53. restart Restart the stack in dependency order
  54. status Show container and workload health
  55. logs <service> [-f] Show logs for app, postgres, or redis
  56. pull Pull all stack images for linux/arm64
  57. destroy [options] Delete stack containers and network
  58. Destroy options:
  59. --volumes Also delete all persistent data volumes
  60. --yes Skip the confirmation prompt
  61. Environment:
  62. SUB2API_ENV_FILE Path to the deployment env file (default: deploy/.env)
  63. EOF
  64. }
  65. cleanup() {
  66. local exit_code=$?
  67. if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
  68. rm -rf "${TEMP_DIR}"
  69. fi
  70. if [[ "${LOCK_ACQUIRED}" == true && -d "${LOCK_DIR}" ]]; then
  71. rm -f "${LOCK_DIR}/pid"
  72. rmdir "${LOCK_DIR}" 2>/dev/null || true
  73. fi
  74. exit "${exit_code}"
  75. }
  76. acquire_lock() {
  77. if ! mkdir "${LOCK_DIR}" 2>/dev/null; then
  78. local owner_pid=""
  79. if [[ -f "${LOCK_DIR}/pid" ]]; then
  80. owner_pid="$(<"${LOCK_DIR}/pid")"
  81. fi
  82. if [[ "${owner_pid}" =~ ^[0-9]+$ ]] && ! kill -0 "${owner_pid}" 2>/dev/null; then
  83. rm -rf "${LOCK_DIR}"
  84. mkdir "${LOCK_DIR}" || die "Failed to reclaim stale operation lock."
  85. else
  86. die "Another Sub2API Apple container operation is already running."
  87. fi
  88. fi
  89. printf '%s\n' "$$" >"${LOCK_DIR}/pid"
  90. LOCK_ACQUIRED=true
  91. trap cleanup EXIT
  92. trap 'exit 130' INT
  93. trap 'exit 143' TERM
  94. trap 'exit 129' HUP
  95. }
  96. require_command() {
  97. command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
  98. }
  99. require_container_version() {
  100. local version_output major minor
  101. require_command container
  102. require_command plutil
  103. version_output="$(container --version)"
  104. if [[ ! "${version_output}" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
  105. die "Unable to parse Apple container version: ${version_output}"
  106. fi
  107. major="${BASH_REMATCH[1]}"
  108. minor="${BASH_REMATCH[2]}"
  109. if (( major < 1 || (major == 1 && minor < 1) )); then
  110. die "Apple container 1.1.0 or newer is required; found ${version_output}."
  111. fi
  112. }
  113. system_is_running() {
  114. container system status >/dev/null 2>&1
  115. }
  116. start_system() {
  117. if ! system_is_running; then
  118. info "Starting Apple container services..."
  119. container system start --enable-kernel-install
  120. fi
  121. }
  122. list_resource_ids() {
  123. case "$1" in
  124. container) container list --all --quiet ;;
  125. network) container network list --quiet ;;
  126. volume) container volume list --quiet ;;
  127. *) die "Unknown resource type: $1" ;;
  128. esac
  129. }
  130. resource_exists() {
  131. local resource_type=$1
  132. local resource_name=$2
  133. local output line
  134. if ! output="$(list_resource_ids "${resource_type}")"; then
  135. die "Failed to list Apple container ${resource_type} resources."
  136. fi
  137. while IFS= read -r line; do
  138. if [[ "${line}" == "${resource_name}" ]]; then
  139. return 0
  140. fi
  141. done <<<"${output}"
  142. return 1
  143. }
  144. inspect_resource() {
  145. case "$1" in
  146. container) container inspect "$2" ;;
  147. network) container network inspect "$2" ;;
  148. volume) container volume inspect "$2" ;;
  149. *) die "Unknown resource type: $1" ;;
  150. esac
  151. }
  152. assert_resource_owned() {
  153. local resource_type=$1
  154. local resource_name=$2
  155. local inspection compact
  156. inspection="$(inspect_resource "${resource_type}" "${resource_name}" | \
  157. plutil -extract 0.configuration.labels json -o - -)" || \
  158. die "Failed to inspect ${resource_type} ${resource_name}."
  159. compact="$(printf '%s' "${inspection}" | tr -d '[:space:]')"
  160. if [[ "${compact}" != *"\"${STACK_LABEL_KEY}\":\"${STACK_LABEL_VALUE}\""* ]]; then
  161. die "Refusing to manage existing ${resource_type} '${resource_name}' because it is not owned by this stack."
  162. fi
  163. }
  164. preflight_stack_ownership() {
  165. local resource_name
  166. for resource_name in "${APP_CONTAINER}" "${REDIS_CONTAINER}" "${POSTGRES_CONTAINER}"; do
  167. if resource_exists container "${resource_name}"; then
  168. assert_resource_owned container "${resource_name}"
  169. fi
  170. done
  171. if resource_exists network "${NETWORK_NAME}"; then
  172. assert_resource_owned network "${NETWORK_NAME}"
  173. fi
  174. for resource_name in "${APP_VOLUME}" "${REDIS_VOLUME}" "${POSTGRES_VOLUME}"; do
  175. if resource_exists volume "${resource_name}"; then
  176. assert_resource_owned volume "${resource_name}"
  177. fi
  178. done
  179. }
  180. ensure_network() {
  181. local existing_subnet
  182. local -a network_args
  183. if resource_exists network "${NETWORK_NAME}"; then
  184. assert_resource_owned network "${NETWORK_NAME}"
  185. if [[ -n "${NETWORK_SUBNET}" ]]; then
  186. existing_subnet="$(inspect_resource network "${NETWORK_NAME}" | \
  187. plutil -extract 0.configuration.ipv4Subnet raw -o - -)" || \
  188. die "Unable to read the IPv4 subnet for ${NETWORK_NAME}."
  189. if [[ "${existing_subnet}" != "${NETWORK_SUBNET}" ]]; then
  190. 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."
  191. fi
  192. fi
  193. return
  194. fi
  195. info "Creating network ${NETWORK_NAME}..."
  196. network_args=(--label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}")
  197. if [[ -n "${NETWORK_SUBNET}" ]]; then
  198. network_args+=(--subnet "${NETWORK_SUBNET}")
  199. fi
  200. container network create "${network_args[@]}" "${NETWORK_NAME}" >/dev/null
  201. }
  202. ensure_volume() {
  203. local volume_name=$1
  204. if resource_exists volume "${volume_name}"; then
  205. assert_resource_owned volume "${volume_name}"
  206. return
  207. fi
  208. info "Creating volume ${volume_name}..."
  209. container volume create \
  210. --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
  211. "${volume_name}" >/dev/null
  212. }
  213. ensure_image_available() {
  214. local image=$1
  215. if container image inspect "${image}" >/dev/null 2>&1; then
  216. return
  217. fi
  218. info "Pulling ${image}..."
  219. container image pull --platform "${PLATFORM}" "${image}"
  220. }
  221. container_is_running() {
  222. local container_name=$1
  223. local output line
  224. output="$(container list --quiet)" || die "Failed to list running Apple containers."
  225. while IFS= read -r line; do
  226. if [[ "${line}" == "${container_name}" ]]; then
  227. return 0
  228. fi
  229. done <<<"${output}"
  230. return 1
  231. }
  232. ensure_system() {
  233. require_container_version
  234. require_command curl
  235. start_system
  236. }
  237. container_ipv4_address() {
  238. local container_name=$1
  239. local address
  240. address="$(container inspect "${container_name}" | \
  241. plutil -extract 0.status.networks.0.ipv4Address raw -o - -)" || \
  242. die "Unable to read the network address for ${container_name}."
  243. address="${address%%/*}"
  244. [[ "${address}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
  245. die "Apple container returned an invalid IPv4 address for ${container_name}: ${address}"
  246. printf '%s\n' "${address}"
  247. }
  248. read_env_value() {
  249. local key=$1
  250. local fallback=${2-}
  251. awk -v wanted="${key}" -v fallback="${fallback}" '
  252. BEGIN { found = 0 }
  253. /^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
  254. {
  255. separator = index($0, "=")
  256. if (separator == 0) { next }
  257. key = substr($0, 1, separator - 1)
  258. if (key == wanted) {
  259. value = substr($0, separator + 1)
  260. sub(/\r$/, "", value)
  261. found = 1
  262. }
  263. }
  264. END {
  265. if (found) { print value }
  266. else { print fallback }
  267. }
  268. ' "${ENV_FILE}"
  269. }
  270. replace_env_value() {
  271. local key=$1
  272. local value=$2
  273. local target_file=${3:-${ENV_FILE}}
  274. local temp_file="${target_file}.tmp.$$"
  275. awk -v wanted="${key}" -v replacement="${value}" '
  276. BEGIN { replaced = 0 }
  277. {
  278. separator = index($0, "=")
  279. key = separator == 0 ? "" : substr($0, 1, separator - 1)
  280. if (key == wanted) {
  281. if (!replaced) { print wanted "=" replacement }
  282. replaced = 1
  283. next
  284. }
  285. print
  286. }
  287. END {
  288. if (!replaced) { print wanted "=" replacement }
  289. }
  290. ' "${target_file}" >"${temp_file}"
  291. chmod 600 "${temp_file}"
  292. mv "${temp_file}" "${target_file}"
  293. }
  294. generate_secret() {
  295. openssl rand -hex 32
  296. }
  297. cmd_init() {
  298. local env_dir temp_file postgres_secret jwt_secret totp_secret
  299. require_command openssl
  300. if [[ -e "${ENV_FILE}" ]]; then
  301. die "Environment file already exists: ${ENV_FILE}"
  302. fi
  303. postgres_secret="$(generate_secret)" || die "Failed to generate PostgreSQL password."
  304. jwt_secret="$(generate_secret)" || die "Failed to generate JWT secret."
  305. totp_secret="$(generate_secret)" || die "Failed to generate TOTP encryption key."
  306. [[ -n "${postgres_secret}" && -n "${jwt_secret}" && -n "${totp_secret}" ]] || \
  307. die "Secret generation returned an empty value."
  308. env_dir="$(dirname "${ENV_FILE}")"
  309. temp_file="${ENV_FILE}.init.tmp.$$"
  310. mkdir -p "${env_dir}"
  311. cp "${SCRIPT_DIR}/.env.example" "${temp_file}"
  312. chmod 600 "${temp_file}"
  313. replace_env_value POSTGRES_PASSWORD "${postgres_secret}" "${temp_file}"
  314. replace_env_value JWT_SECRET "${jwt_secret}" "${temp_file}"
  315. replace_env_value TOTP_ENCRYPTION_KEY "${totp_secret}" "${temp_file}"
  316. mv "${temp_file}" "${ENV_FILE}"
  317. info "Created ${ENV_FILE} with generated secrets."
  318. info "Review the file, then run: SUB2API_ENV_FILE='${ENV_FILE}' ${SCRIPT_DIR}/apple-container.sh up"
  319. }
  320. validate_port() {
  321. local port=$1
  322. local decimal_port
  323. [[ "${port}" =~ ^[0-9]+$ ]] || die "SERVER_PORT must be numeric: ${port}"
  324. decimal_port=$((10#${port}))
  325. (( decimal_port >= 1025 && decimal_port <= 65535 )) || \
  326. die "SERVER_PORT must be between 1025 and 65535 for Apple container port forwarding."
  327. }
  328. validate_ipv4_address() {
  329. local address=$1
  330. local first second third fourth extra octet
  331. IFS=. read -r first second third fourth extra <<<"${address}"
  332. [[ -n "${first}" && -n "${second}" && -n "${third}" && -n "${fourth}" && -z "${extra}" ]] || \
  333. die "BIND_HOST must be a valid IPv4 address: ${address}"
  334. for octet in "${first}" "${second}" "${third}" "${fourth}"; do
  335. [[ "${octet}" =~ ^[0-9]+$ ]] || die "BIND_HOST must be a valid IPv4 address: ${address}"
  336. (( 10#${octet} <= 255 )) || die "BIND_HOST must be a valid IPv4 address: ${address}"
  337. done
  338. }
  339. validate_env_file_security() {
  340. local owner mode permissions
  341. [[ -f "${ENV_FILE}" ]] || die "Environment file not found: ${ENV_FILE}. Run '$0 init' first."
  342. owner="$(stat -f '%u' "${ENV_FILE}")" || die "Unable to read owner for ${ENV_FILE}."
  343. mode="$(stat -f '%Lp' "${ENV_FILE}")" || die "Unable to read permissions for ${ENV_FILE}."
  344. [[ "${owner}" == "${EUID}" ]] || die "Environment file must be owned by the current user: ${ENV_FILE}"
  345. [[ "${mode}" =~ ^[0-7]+$ ]] || die "Unable to parse permissions for ${ENV_FILE}: ${mode}"
  346. permissions=$((8#${mode}))
  347. (( (permissions & 077) == 0 )) || \
  348. die "Environment file must not be readable by group or others. Run: chmod 600 '${ENV_FILE}'"
  349. }
  350. prepare_environment() {
  351. validate_env_file_security
  352. APP_IMAGE="$(read_env_value APPLE_CONTAINER_SUB2API_IMAGE weishaw/sub2api:latest)"
  353. POSTGRES_IMAGE="$(read_env_value APPLE_CONTAINER_POSTGRES_IMAGE postgres:18-alpine)"
  354. REDIS_IMAGE="$(read_env_value APPLE_CONTAINER_REDIS_IMAGE redis:8-alpine)"
  355. BIND_HOST="$(read_env_value BIND_HOST 0.0.0.0)"
  356. HOST_PORT="$(read_env_value SERVER_PORT 8080)"
  357. POSTGRES_USER="$(read_env_value POSTGRES_USER sub2api)"
  358. POSTGRES_PASSWORD="$(read_env_value POSTGRES_PASSWORD)"
  359. POSTGRES_DB="$(read_env_value POSTGRES_DB sub2api)"
  360. REDIS_PASSWORD="$(read_env_value REDIS_PASSWORD)"
  361. TZ_VALUE="$(read_env_value TZ Asia/Shanghai)"
  362. NETWORK_SUBNET="$(read_env_value APPLE_CONTAINER_NETWORK_SUBNET)"
  363. [[ -n "${BIND_HOST}" ]] || die "BIND_HOST must not be empty."
  364. validate_ipv4_address "${BIND_HOST}"
  365. validate_port "${HOST_PORT}"
  366. if [[ "${BIND_HOST}" == "0.0.0.0" ]]; then
  367. ACCESS_HOST="127.0.0.1"
  368. else
  369. ACCESS_HOST="${BIND_HOST}"
  370. fi
  371. [[ -n "${POSTGRES_USER}" ]] || die "POSTGRES_USER must not be empty."
  372. [[ -n "${POSTGRES_DB}" ]] || die "POSTGRES_DB must not be empty."
  373. if [[ -z "${POSTGRES_PASSWORD}" || "${POSTGRES_PASSWORD}" == "change_this_secure_password" ]]; then
  374. die "Set a secure POSTGRES_PASSWORD in ${ENV_FILE}."
  375. fi
  376. TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/sub2api-apple.XXXXXX")"
  377. APP_ENV_FILE="${TEMP_DIR}/app.env"
  378. POSTGRES_ENV_FILE="${TEMP_DIR}/postgres.env"
  379. POSTGRES_PROBE_ENV_FILE="${TEMP_DIR}/postgres-probe.env"
  380. REDIS_ENV_FILE="${TEMP_DIR}/redis.env"
  381. cat >"${POSTGRES_ENV_FILE}" <<EOF
  382. POSTGRES_USER=${POSTGRES_USER}
  383. POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
  384. POSTGRES_DB=${POSTGRES_DB}
  385. TZ=${TZ_VALUE}
  386. EOF
  387. cat >"${POSTGRES_PROBE_ENV_FILE}" <<EOF
  388. PGPASSWORD=${POSTGRES_PASSWORD}
  389. EOF
  390. cat >"${REDIS_ENV_FILE}" <<EOF
  391. REDIS_PASSWORD=${REDIS_PASSWORD}
  392. TZ=${TZ_VALUE}
  393. EOF
  394. if [[ -n "${REDIS_PASSWORD}" ]]; then
  395. printf 'REDISCLI_AUTH=%s\n' "${REDIS_PASSWORD}" >>"${REDIS_ENV_FILE}"
  396. fi
  397. chmod 600 "${POSTGRES_ENV_FILE}" "${POSTGRES_PROBE_ENV_FILE}" "${REDIS_ENV_FILE}"
  398. }
  399. prepare_app_environment() {
  400. [[ -n "${POSTGRES_ADDRESS}" && -n "${REDIS_ADDRESS}" ]] || \
  401. die "Dependency network addresses are not available."
  402. cp "${ENV_FILE}" "${APP_ENV_FILE}"
  403. cat >>"${APP_ENV_FILE}" <<EOF
  404. AUTO_SETUP=true
  405. SERVER_HOST=0.0.0.0
  406. SERVER_PORT=8080
  407. DATABASE_HOST=${POSTGRES_ADDRESS}
  408. DATABASE_PORT=5432
  409. DATABASE_USER=${POSTGRES_USER}
  410. DATABASE_PASSWORD=${POSTGRES_PASSWORD}
  411. DATABASE_DBNAME=${POSTGRES_DB}
  412. DATABASE_SSLMODE=disable
  413. REDIS_HOST=${REDIS_ADDRESS}
  414. REDIS_PORT=6379
  415. REDIS_PASSWORD=${REDIS_PASSWORD}
  416. DATA_DIR=/app/storage/data
  417. EOF
  418. chmod 600 "${APP_ENV_FILE}"
  419. }
  420. create_postgres_container() {
  421. info "Creating PostgreSQL container..."
  422. container create \
  423. --name "${POSTGRES_CONTAINER}" \
  424. --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
  425. --network "${NETWORK_NAME}" \
  426. --platform "${PLATFORM}" \
  427. --ulimit nofile=100000:100000 \
  428. --env-file "${POSTGRES_ENV_FILE}" \
  429. --volume "${POSTGRES_VOLUME}:/var/lib/postgresql" \
  430. "${POSTGRES_IMAGE}" >/dev/null
  431. }
  432. create_redis_container() {
  433. info "Creating Redis container..."
  434. container create \
  435. --name "${REDIS_CONTAINER}" \
  436. --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
  437. --network "${NETWORK_NAME}" \
  438. --platform "${PLATFORM}" \
  439. --ulimit nofile=100000:100000 \
  440. --env-file "${REDIS_ENV_FILE}" \
  441. --volume "${REDIS_VOLUME}:/var/lib/redis" \
  442. "${REDIS_IMAGE}" \
  443. 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"}' \
  444. >/dev/null
  445. }
  446. create_app_container() {
  447. info "Creating Sub2API container..."
  448. container create \
  449. --name "${APP_CONTAINER}" \
  450. --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \
  451. --network "${NETWORK_NAME}" \
  452. --platform "${PLATFORM}" \
  453. --ulimit nofile=100000:100000 \
  454. --publish "${BIND_HOST}:${HOST_PORT}:8080/tcp" \
  455. --env-file "${APP_ENV_FILE}" \
  456. --volume "${APP_VOLUME}:/app/storage" \
  457. --entrypoint /bin/sh \
  458. "${APP_IMAGE}" \
  459. -c 'set -e; mkdir -p "$DATA_DIR"; chown -R sub2api:sub2api "$DATA_DIR"; exec su-exec sub2api /app/sub2api' \
  460. >/dev/null
  461. }
  462. ensure_container() {
  463. local container_name=$1
  464. local create_function=$2
  465. if resource_exists container "${container_name}"; then
  466. assert_resource_owned container "${container_name}"
  467. return
  468. fi
  469. "${create_function}"
  470. }
  471. start_container_if_needed() {
  472. local container_name=$1
  473. if container_is_running "${container_name}"; then
  474. return
  475. fi
  476. info "Starting ${container_name}..."
  477. container start "${container_name}" >/dev/null
  478. }
  479. stop_container_if_running() {
  480. local container_name=$1
  481. if ! resource_exists container "${container_name}"; then
  482. return
  483. fi
  484. assert_resource_owned container "${container_name}"
  485. if container_is_running "${container_name}"; then
  486. info "Stopping ${container_name}..."
  487. container stop --time 30 "${container_name}" >/dev/null
  488. fi
  489. }
  490. delete_container_if_present() {
  491. local container_name=$1
  492. if ! resource_exists container "${container_name}"; then
  493. return
  494. fi
  495. assert_resource_owned container "${container_name}"
  496. if container_is_running "${container_name}"; then
  497. container stop --time 30 "${container_name}" >/dev/null
  498. fi
  499. info "Deleting ${container_name}..."
  500. container delete "${container_name}" >/dev/null
  501. }
  502. wait_for_probe() {
  503. local description=$1
  504. local attempts=$2
  505. shift 2
  506. local attempt
  507. for ((attempt = 1; attempt <= attempts; attempt++)); do
  508. if "$@" >/dev/null 2>&1; then
  509. info "${description} is ready."
  510. return 0
  511. fi
  512. sleep 1
  513. done
  514. return 1
  515. }
  516. probe_postgres() {
  517. container exec --env-file "${POSTGRES_PROBE_ENV_FILE}" \
  518. "${POSTGRES_CONTAINER}" \
  519. psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
  520. -v ON_ERROR_STOP=1 -tAc 'SELECT 1'
  521. }
  522. probe_redis() {
  523. container exec --env-file "${REDIS_ENV_FILE}" \
  524. "${REDIS_CONTAINER}" \
  525. redis-cli ping
  526. }
  527. probe_app() {
  528. container exec "${APP_CONTAINER}" \
  529. wget -q -T 5 -O /dev/null http://localhost:8080/health
  530. }
  531. probe_host_app() {
  532. curl --fail --silent --show-error --max-time 5 \
  533. "http://${ACCESS_HOST}:${HOST_PORT}/health"
  534. }
  535. show_failure_logs() {
  536. local container_name=$1
  537. warn "Last logs from ${container_name}:"
  538. container logs -n 50 "${container_name}" >&2 || true
  539. }
  540. start_dependencies() {
  541. start_container_if_needed "${POSTGRES_CONTAINER}"
  542. if ! wait_for_probe "PostgreSQL" 90 probe_postgres; then
  543. show_failure_logs "${POSTGRES_CONTAINER}"
  544. die "PostgreSQL did not become ready."
  545. fi
  546. start_container_if_needed "${REDIS_CONTAINER}"
  547. if ! wait_for_probe "Redis" 60 probe_redis; then
  548. show_failure_logs "${REDIS_CONTAINER}"
  549. die "Redis did not become ready."
  550. fi
  551. }
  552. start_app() {
  553. start_container_if_needed "${APP_CONTAINER}"
  554. if ! wait_for_probe "Sub2API" 180 probe_app; then
  555. show_failure_logs "${APP_CONTAINER}"
  556. die "Sub2API did not become ready."
  557. fi
  558. if ! wait_for_probe "Sub2API host port" 15 probe_host_app; then
  559. 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."
  560. fi
  561. }
  562. cmd_up() {
  563. local recreate=false
  564. if [[ $# -gt 1 || ($# -eq 1 && "${1-}" != "--recreate") ]]; then
  565. usage
  566. exit 2
  567. fi
  568. if [[ $# -eq 1 ]]; then
  569. recreate=true
  570. fi
  571. ensure_system
  572. prepare_environment
  573. preflight_stack_ownership
  574. ensure_network
  575. ensure_volume "${APP_VOLUME}"
  576. ensure_volume "${POSTGRES_VOLUME}"
  577. ensure_volume "${REDIS_VOLUME}"
  578. ensure_image_available "${APP_IMAGE}"
  579. ensure_image_available "${POSTGRES_IMAGE}"
  580. ensure_image_available "${REDIS_IMAGE}"
  581. if [[ "${recreate}" == true ]]; then
  582. delete_container_if_present "${APP_CONTAINER}"
  583. delete_container_if_present "${REDIS_CONTAINER}"
  584. delete_container_if_present "${POSTGRES_CONTAINER}"
  585. fi
  586. ensure_container "${POSTGRES_CONTAINER}" create_postgres_container
  587. ensure_container "${REDIS_CONTAINER}" create_redis_container
  588. start_dependencies
  589. POSTGRES_ADDRESS="$(container_ipv4_address "${POSTGRES_CONTAINER}")"
  590. REDIS_ADDRESS="$(container_ipv4_address "${REDIS_CONTAINER}")"
  591. prepare_app_environment
  592. # The dependency IPs may change whenever their lightweight VMs restart.
  593. delete_container_if_present "${APP_CONTAINER}"
  594. create_app_container
  595. start_app
  596. info "Sub2API is available at http://${ACCESS_HOST}:${HOST_PORT}"
  597. }
  598. cmd_down() {
  599. require_container_version
  600. if ! system_is_running; then
  601. info "Apple container services are already stopped."
  602. return
  603. fi
  604. preflight_stack_ownership
  605. stop_container_if_running "${APP_CONTAINER}"
  606. stop_container_if_running "${REDIS_CONTAINER}"
  607. stop_container_if_running "${POSTGRES_CONTAINER}"
  608. info "Sub2API stack stopped; persistent volumes were preserved."
  609. }
  610. cmd_restart() {
  611. cmd_down
  612. cmd_up
  613. }
  614. print_container_status() {
  615. local service=$1
  616. local container_name=$2
  617. if ! resource_exists container "${container_name}"; then
  618. printf '%-12s %s\n' "${service}" "missing"
  619. elif container_is_running "${container_name}"; then
  620. printf '%-12s %s\n' "${service}" "running"
  621. else
  622. printf '%-12s %s\n' "${service}" "stopped"
  623. fi
  624. }
  625. cmd_status() {
  626. local failed=0
  627. require_container_version
  628. if ! system_is_running; then
  629. printf '%-12s %s\n' "system" "stopped"
  630. return 1
  631. fi
  632. printf '%-12s %s\n' "system" "running"
  633. preflight_stack_ownership
  634. print_container_status app "${APP_CONTAINER}"
  635. print_container_status postgres "${POSTGRES_CONTAINER}"
  636. print_container_status redis "${REDIS_CONTAINER}"
  637. if [[ -f "${ENV_FILE}" ]]; then
  638. prepare_environment
  639. if container_is_running "${POSTGRES_CONTAINER}" && probe_postgres >/dev/null 2>&1; then
  640. printf '%-12s %s\n' "postgres" "healthy"
  641. else
  642. printf '%-12s %s\n' "postgres" "unhealthy"
  643. failed=1
  644. fi
  645. if container_is_running "${REDIS_CONTAINER}" && probe_redis >/dev/null 2>&1; then
  646. printf '%-12s %s\n' "redis" "healthy"
  647. else
  648. printf '%-12s %s\n' "redis" "unhealthy"
  649. failed=1
  650. fi
  651. if container_is_running "${APP_CONTAINER}" && probe_app >/dev/null 2>&1; then
  652. printf '%-12s %s\n' "app" "healthy"
  653. else
  654. printf '%-12s %s\n' "app" "unhealthy"
  655. failed=1
  656. fi
  657. if container_is_running "${APP_CONTAINER}" && probe_host_app >/dev/null 2>&1; then
  658. printf '%-12s %s\n' "host-port" "healthy"
  659. else
  660. printf '%-12s %s\n' "host-port" "unhealthy"
  661. failed=1
  662. fi
  663. else
  664. warn "Health probes require ${ENV_FILE}."
  665. failed=1
  666. fi
  667. return "${failed}"
  668. }
  669. cmd_logs() {
  670. local service=${1-}
  671. local follow=${2-}
  672. local container_name
  673. [[ $# -ge 1 && $# -le 2 ]] || { usage; exit 2; }
  674. if [[ -n "${follow}" && "${follow}" != "-f" && "${follow}" != "--follow" ]]; then
  675. usage
  676. exit 2
  677. fi
  678. case "${service}" in
  679. app|sub2api) container_name="${APP_CONTAINER}" ;;
  680. postgres) container_name="${POSTGRES_CONTAINER}" ;;
  681. redis) container_name="${REDIS_CONTAINER}" ;;
  682. *) die "Unknown service '${service}'. Use app, postgres, or redis." ;;
  683. esac
  684. require_container_version
  685. system_is_running || die "Apple container services are not running."
  686. resource_exists container "${container_name}" || die "Container not found: ${container_name}"
  687. assert_resource_owned container "${container_name}"
  688. if [[ -n "${follow}" ]]; then
  689. container logs --follow "${container_name}"
  690. else
  691. container logs "${container_name}"
  692. fi
  693. }
  694. cmd_pull() {
  695. ensure_system
  696. prepare_environment
  697. info "Pulling ${APP_IMAGE}..."
  698. container image pull --platform "${PLATFORM}" "${APP_IMAGE}"
  699. info "Pulling ${POSTGRES_IMAGE}..."
  700. container image pull --platform "${PLATFORM}" "${POSTGRES_IMAGE}"
  701. info "Pulling ${REDIS_IMAGE}..."
  702. container image pull --platform "${PLATFORM}" "${REDIS_IMAGE}"
  703. }
  704. confirm_destroy() {
  705. local include_volumes=$1
  706. local answer
  707. if [[ "${include_volumes}" == true ]]; then
  708. printf 'Delete the Sub2API stack and all persistent data? [y/N] '
  709. else
  710. printf 'Delete the Sub2API containers and network, preserving volumes? [y/N] '
  711. fi
  712. read -r answer
  713. [[ "${answer}" == "y" || "${answer}" == "Y" ]]
  714. }
  715. delete_volume_if_present() {
  716. local volume_name=$1
  717. if resource_exists volume "${volume_name}"; then
  718. assert_resource_owned volume "${volume_name}"
  719. info "Deleting volume ${volume_name}..."
  720. container volume delete "${volume_name}" >/dev/null
  721. fi
  722. }
  723. cmd_destroy() {
  724. local include_volumes=false
  725. local assume_yes=false
  726. local argument
  727. for argument in "$@"; do
  728. case "${argument}" in
  729. --volumes) include_volumes=true ;;
  730. --yes) assume_yes=true ;;
  731. *) usage; exit 2 ;;
  732. esac
  733. done
  734. require_container_version
  735. start_system
  736. preflight_stack_ownership
  737. if [[ "${assume_yes}" != true ]] && ! confirm_destroy "${include_volumes}"; then
  738. info "Cancelled."
  739. return
  740. fi
  741. delete_container_if_present "${APP_CONTAINER}"
  742. delete_container_if_present "${REDIS_CONTAINER}"
  743. delete_container_if_present "${POSTGRES_CONTAINER}"
  744. if resource_exists network "${NETWORK_NAME}"; then
  745. assert_resource_owned network "${NETWORK_NAME}"
  746. info "Deleting network ${NETWORK_NAME}..."
  747. container network delete "${NETWORK_NAME}" >/dev/null
  748. fi
  749. if [[ "${include_volumes}" == true ]]; then
  750. delete_volume_if_present "${APP_VOLUME}"
  751. delete_volume_if_present "${REDIS_VOLUME}"
  752. delete_volume_if_present "${POSTGRES_VOLUME}"
  753. info "Sub2API stack and persistent data deleted."
  754. else
  755. info "Sub2API stack deleted; persistent volumes were preserved."
  756. fi
  757. }
  758. main() {
  759. local command=${1-}
  760. if [[ $# -gt 0 ]]; then
  761. shift
  762. fi
  763. case "${command}" in
  764. init)
  765. [[ $# -eq 0 ]] || { usage; exit 2; }
  766. acquire_lock
  767. cmd_init
  768. ;;
  769. up)
  770. acquire_lock
  771. cmd_up "$@"
  772. ;;
  773. down)
  774. [[ $# -eq 0 ]] || { usage; exit 2; }
  775. acquire_lock
  776. cmd_down
  777. ;;
  778. restart)
  779. [[ $# -eq 0 ]] || { usage; exit 2; }
  780. acquire_lock
  781. cmd_restart
  782. ;;
  783. status)
  784. [[ $# -eq 0 ]] || { usage; exit 2; }
  785. trap cleanup EXIT
  786. cmd_status
  787. ;;
  788. logs)
  789. cmd_logs "$@"
  790. ;;
  791. pull)
  792. [[ $# -eq 0 ]] || { usage; exit 2; }
  793. acquire_lock
  794. cmd_pull
  795. ;;
  796. destroy)
  797. acquire_lock
  798. cmd_destroy "$@"
  799. ;;
  800. help|-h|--help)
  801. usage
  802. ;;
  803. *)
  804. usage
  805. exit 2
  806. ;;
  807. esac
  808. }
  809. main "$@"