helpers.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. #!/bin/bash
  2. set -e -o errexit -o nounset -o pipefail
  3. [[ ${DOCKER_APP_ENTRYPOINT_DEBUG:=0} == 1 ]] && set -x
  4. : "${RUNTIME_UID:="33"}"
  5. : "${RUNTIME_GID:="33"}"
  6. # Some splash of color for important messages
  7. declare -g error_message_color="\033[1;31m"
  8. declare -g warn_message_color="\033[1;33m"
  9. declare -g notice_message_color="\033[1;34m"
  10. declare -g success_message_color="\033[1;32m"
  11. # shellcheck disable=SC2034
  12. declare -g section_message_color="\033[1;35m"
  13. declare -g color_clear="\033[1;0m"
  14. # Current and previous log prefix
  15. declare -g script_name=
  16. declare -g script_name_previous=
  17. declare -g log_prefix=
  18. # dot-env files to source when reading config
  19. declare -a dot_env_files=(
  20. /var/www/.env.docker
  21. /var/www/.env
  22. )
  23. # environment keys seen when source dot files (so we can [export] them)
  24. declare -ga seen_dot_env_variables=()
  25. declare -g docker_state_path
  26. docker_state_path="$(readlink -f ./storage/docker)"
  27. declare -g docker_locks_path="${docker_state_path}/lock"
  28. declare -g docker_once_path="${docker_state_path}/once"
  29. declare -g runtime_username
  30. runtime_username=$(id -un "${RUNTIME_UID}")
  31. # We should already be in /var/www, but just to be explicit
  32. cd /var/www || log-error-and-exit "could not change to /var/www"
  33. # @description Restore the log prefix to the previous value that was captured in [entrypoint-set-script-name ]
  34. # @arg $1 string The name (or path) of the entrypoint script being run
  35. function entrypoint-set-script-name()
  36. {
  37. script_name_previous="${script_name}"
  38. script_name="${1}"
  39. log_prefix="[entrypoint / $(get-entrypoint-script-name "$1")] - "
  40. }
  41. # @description Restore the log prefix to the previous value that was captured in [entrypoint-set-script-name ]
  42. function entrypoint-restore-script-name()
  43. {
  44. entrypoint-set-script-name "${script_name_previous}"
  45. }
  46. # @description Run a command as the [runtime user]
  47. # @arg $@ string The command to run
  48. # @exitcode 0 if the command succeeeds
  49. # @exitcode 1 if the command fails
  50. function run-as-runtime-user()
  51. {
  52. run-command-as "${runtime_username}" "${@}"
  53. }
  54. # @description Run a command as the [runtime user]
  55. # @arg $@ string The command to run
  56. # @exitcode 0 if the command succeeeds
  57. # @exitcode 1 if the command fails
  58. function run-as-current-user()
  59. {
  60. run-command-as "$(id -un)" "${@}"
  61. }
  62. # @description Run a command as the a named user
  63. # @arg $1 string The user to run the command as
  64. # @arg $@ string The command to run
  65. # @exitcode 0 If the command succeeeds
  66. # @exitcode 1 If the command fails
  67. function run-command-as()
  68. {
  69. local -i exit_code
  70. local target_user
  71. target_user=${1}
  72. shift
  73. log-info-stderr "${notice_message_color}👷 Running [${*}] as [${target_user}]${color_clear}"
  74. if [[ ${target_user} != "root" ]]; then
  75. stream-prefix-command-output su --preserve-environment "${target_user}" --shell /bin/bash --command "${*}"
  76. else
  77. stream-prefix-command-output "${@}"
  78. fi
  79. exit_code=$?
  80. if [[ $exit_code != 0 ]]; then
  81. log-error "${error_message_color}❌ Error!${color_clear}"
  82. return "$exit_code"
  83. fi
  84. log-info-stderr "${success_message_color}✅ OK!${color_clear}"
  85. return "$exit_code"
  86. }
  87. # @description Streams stdout from the command and echo it
  88. # with log prefixing.
  89. # @see stream-prefix-command-output
  90. function stream-stdout-handler()
  91. {
  92. while read -r line; do
  93. log-info "(stdout) ${line}"
  94. done
  95. }
  96. # @description Streams stderr from the command and echo it
  97. # with a bit of color and log prefixing.
  98. # @see stream-prefix-command-output
  99. function stream-stderr-handler()
  100. {
  101. while read -r line; do
  102. log-info-stderr "(${error_message_color}stderr${color_clear}) ${line}"
  103. done
  104. }
  105. # @description Steam stdout and stderr from a command with log prefix
  106. # and stdout/stderr prefix. If stdout or stderr is being piped/redirected
  107. # it will automatically fall back to non-prefixed output.
  108. # @arg $@ string The command to run
  109. function stream-prefix-command-output()
  110. {
  111. local stdout=stream-stdout-handler
  112. local stderr=stream-stderr-handler
  113. # if stdout is being piped, print it like normal with echo
  114. if [ ! -t 1 ]; then
  115. # shellcheck disable=SC1007
  116. stdout= echo >&1 -ne
  117. fi
  118. # if stderr is being piped, print it like normal with echo
  119. if [ ! -t 2 ]; then
  120. # shellcheck disable=SC1007
  121. stderr= echo >&2 -ne
  122. fi
  123. "$@" > >($stdout) 2> >($stderr)
  124. }
  125. # @description Print the given error message to stderr
  126. # @arg $message string A error message.
  127. # @stderr The error message provided with log prefix
  128. function log-error()
  129. {
  130. local msg
  131. if [[ $# -gt 0 ]]; then
  132. msg="$*"
  133. elif [[ ! -t 0 ]]; then
  134. read -r msg || log-error-and-exit "[${FUNCNAME[0]}] could not read from stdin"
  135. else
  136. log-error-and-exit "[${FUNCNAME[0]}] did not receive any input arguments and STDIN is empty"
  137. fi
  138. echo -e "${error_message_color}${log_prefix}ERROR -${color_clear} ${msg}" > /dev/stderr
  139. }
  140. # @description Print the given error message to stderr and exit 1
  141. # @arg $@ string A error message.
  142. # @stderr The error message provided with log prefix
  143. # @exitcode 1
  144. function log-error-and-exit()
  145. {
  146. log-error "$@"
  147. show-call-stack
  148. exit 1
  149. }
  150. # @description Print the given warning message to stderr
  151. # @arg $@ string A warning message.
  152. # @stderr The warning message provided with log prefix
  153. function log-warning()
  154. {
  155. local msg
  156. if [[ $# -gt 0 ]]; then
  157. msg="$*"
  158. elif [[ ! -t 0 ]]; then
  159. read -r msg || log-error-and-exit "[${FUNCNAME[0]}] could not read from stdin"
  160. else
  161. log-error-and-exit "[${FUNCNAME[0]}] did not receive any input arguments and STDIN is empty"
  162. fi
  163. echo -e "${warn_message_color}${log_prefix}WARNING -${color_clear} ${msg}" > /dev/stderr
  164. }
  165. # @description Print the given message to stdout unless [ENTRYPOINT_QUIET_LOGS] is set
  166. # @arg $@ string A info message.
  167. # @stdout The info message provided with log prefix unless $ENTRYPOINT_QUIET_LOGS
  168. function log-info()
  169. {
  170. local msg
  171. if [[ $# -gt 0 ]]; then
  172. msg="$*"
  173. elif [[ ! -t 0 ]]; then
  174. read -r msg || log-error-and-exit "[${FUNCNAME[0]}] could not read from stdin"
  175. else
  176. log-error-and-exit "[${FUNCNAME[0]}] did not receive any input arguments and STDIN is empty"
  177. fi
  178. if [ -z "${ENTRYPOINT_QUIET_LOGS:-}" ]; then
  179. echo -e "${notice_message_color}${log_prefix}${color_clear}${msg}"
  180. fi
  181. }
  182. # @description Print the given message to stderr unless [ENTRYPOINT_QUIET_LOGS] is set
  183. # @arg $@ string A info message.
  184. # @stderr The info message provided with log prefix unless $ENTRYPOINT_QUIET_LOGS
  185. function log-info-stderr()
  186. {
  187. local msg
  188. if [[ $# -gt 0 ]]; then
  189. msg="$*"
  190. elif [[ ! -t 0 ]]; then
  191. read -r msg || log-error-and-exit "[${FUNCNAME[0]}] could not read from stdin"
  192. else
  193. log-error-and-exit "[${FUNCNAME[0]}] did not receive any input arguments and STDIN is empty"
  194. fi
  195. if [ -z "${ENTRYPOINT_QUIET_LOGS:-}" ]; then
  196. echo -e "${notice_message_color}${log_prefix}${color_clear}${msg}" > /dev/stderr
  197. fi
  198. }
  199. # @description Loads the dot-env files used by Docker and track the keys present in the configuration.
  200. # @sets seen_dot_env_variables array List of config keys discovered during loading
  201. function load-config-files()
  202. {
  203. # Associative array (aka map/dictionary) holding the unique keys found in dot-env files
  204. local -A _tmp_dot_env_keys
  205. for file in "${dot_env_files[@]}"; do
  206. if ! file-exists "${file}"; then
  207. log-warning "Could not source file [${file}]: does not exists"
  208. continue
  209. fi
  210. log-info "Sourcing ${file}"
  211. # shellcheck disable=SC1090
  212. source "${file}"
  213. # find all keys in the dot-env file and store them in our temp associative array
  214. for k in $(grep -v '^#' "${file}" | cut -d"=" -f1 | xargs); do
  215. _tmp_dot_env_keys[$k]=1
  216. done
  217. done
  218. # Used in other scripts (like templating) for [export]-ing the values
  219. #
  220. # shellcheck disable=SC2034
  221. seen_dot_env_variables=("${!_tmp_dot_env_keys[@]}")
  222. }
  223. # @description Checks if $needle exists in $haystack
  224. # @arg $1 string The needle (value) to search for
  225. # @arg $2 array The haystack (array) to search in
  226. # @exitcode 0 If $needle was found in $haystack
  227. # @exitcode 1 If $needle was *NOT* found in $haystack
  228. function in-array()
  229. {
  230. local -r needle="\<${1}\>"
  231. local -nr haystack=$2
  232. [[ ${haystack[*]} =~ $needle ]]
  233. }
  234. # @description Checks if $1 has executable bit set or not
  235. # @arg $1 string The path to check
  236. # @exitcode 0 If $1 has executable bit
  237. # @exitcode 1 If $1 does *NOT* have executable bit
  238. function is-executable()
  239. {
  240. [[ -x "$1" ]]
  241. }
  242. # @description Checks if $1 is writable or not
  243. # @arg $1 string The path to check
  244. # @exitcode 0 If $1 is writable
  245. # @exitcode 1 If $1 is *NOT* writable
  246. function is-writable()
  247. {
  248. [[ -w "$1" ]]
  249. }
  250. # @description Checks if $1 exists (directory or file)
  251. # @arg $1 string The path to check
  252. # @exitcode 0 If $1 exists
  253. # @exitcode 1 If $1 does *NOT* exists
  254. function path-exists()
  255. {
  256. [[ -e "$1" ]]
  257. }
  258. # @description Checks if $1 exists (file only)
  259. # @arg $1 string The path to check
  260. # @exitcode 0 If $1 exists
  261. # @exitcode 1 If $1 does *NOT* exists
  262. function file-exists()
  263. {
  264. [[ -f "$1" ]]
  265. }
  266. # @description Checks if $1 contains any files or not
  267. # @arg $1 string The path to check
  268. # @exitcode 0 If $1 contains files
  269. # @exitcode 1 If $1 does *NOT* contain files
  270. function is-directory-empty()
  271. {
  272. ! find "${1}" -mindepth 1 -maxdepth 1 -type f -print -quit 2> /dev/null | read -r
  273. }
  274. # @description Ensures a directory exists (via mkdir)
  275. # @arg $1 string The path to create
  276. # @exitcode 0 If $1 If the path exists *or* was created
  277. # @exitcode 1 If $1 If the path does *NOT* exists and could *NOT* be created
  278. function ensure-directory-exists()
  279. {
  280. stream-prefix-command-output mkdir -pv "$@"
  281. }
  282. # @description Find the relative path for a entrypoint script by removing the ENTRYPOINT_D_ROOT prefix
  283. # @arg $1 string The path to manipulate
  284. # @stdout The relative path to the entrypoint script
  285. function get-entrypoint-script-name()
  286. {
  287. echo "${1#"$ENTRYPOINT_D_ROOT"}"
  288. }
  289. # @description Ensure a command is only run once (via a 'lock' file) in the storage directory.
  290. # The 'lock' is only written if the passed in command ($2) successfully ran.
  291. # @arg $1 string The name of the lock file
  292. # @arg $@ string The command to run
  293. function only-once()
  294. {
  295. local name="${1:-$script_name}"
  296. local file="${docker_once_path}/${name}"
  297. shift
  298. if [[ -e "${file}" ]]; then
  299. log-info "Command [${*}] has already run once before (remove file [${file}] to run it again)"
  300. return 0
  301. fi
  302. ensure-directory-exists "$(dirname "${file}")"
  303. if ! "$@"; then
  304. return 1
  305. fi
  306. stream-prefix-command-output touch "${file}"
  307. return 0
  308. }
  309. # @description Best effort file lock to ensure *something* is not running in multiple containers.
  310. # The script uses "trap" to clean up after itself if the script crashes
  311. # @arg $1 string The lock identifier
  312. function acquire-lock()
  313. {
  314. local name="${1:-$script_name}"
  315. local file="${docker_locks_path}/${name}"
  316. ensure-directory-exists "$(dirname "${file}")"
  317. log-info "🔑 Trying to acquire lock: ${file}: "
  318. while file-exists "${file}"; do
  319. log-info "🔒 Waiting on lock ${file}"
  320. staggered-sleep
  321. done
  322. stream-prefix-command-output touch "${file}"
  323. log-info "🔐 Lock acquired [${file}]"
  324. on-trap "release-lock ${name}" EXIT INT QUIT TERM
  325. }
  326. # @description Release a lock aquired by [acquire-lock]
  327. # @arg $1 string The lock identifier
  328. function release-lock()
  329. {
  330. local name="${1:-$script_name}"
  331. local file="${docker_locks_path}/${name}"
  332. log-info "🔓 Releasing lock [${file}]"
  333. stream-prefix-command-output rm -fv "${file}"
  334. }
  335. # @description Helper function to append multiple actions onto
  336. # the bash [trap] logic
  337. # @arg $1 string The command to run
  338. # @arg $@ string The list of trap signals to register
  339. function on-trap()
  340. {
  341. local trap_add_cmd=$1
  342. shift || log-error-and-exit "${FUNCNAME[0]} usage error"
  343. for trap_add_name in "$@"; do
  344. trap -- "$(
  345. # helper fn to get existing trap command from output
  346. # of trap -p
  347. #
  348. # shellcheck disable=SC2317
  349. extract_trap_cmd()
  350. {
  351. printf '%s\n' "${3:-}"
  352. }
  353. # print existing trap command with newline
  354. eval "extract_trap_cmd $(trap -p "${trap_add_name}")"
  355. # print the new trap command
  356. printf '%s\n' "${trap_add_cmd}"
  357. )" "${trap_add_name}" \
  358. || log-error-and-exit "unable to add to trap ${trap_add_name}"
  359. done
  360. }
  361. # Set the trace attribute for the above function.
  362. #
  363. # This is required to modify DEBUG or RETURN traps because functions don't
  364. # inherit them unless the trace attribute is set
  365. declare -f -t on-trap
  366. # @description Waits for the database to be healthy and responsive
  367. function await-database-ready()
  368. {
  369. log-info "❓ Waiting for database to be ready"
  370. load-config-files
  371. case "${DB_CONNECTION:-}" in
  372. mysql)
  373. # shellcheck disable=SC2154
  374. while ! echo "SELECT 1" | mysql --user="${DB_USERNAME}" --password="${DB_PASSWORD}" --host="${DB_HOST}" "${DB_DATABASE}" --silent > /dev/null; do
  375. staggered-sleep
  376. done
  377. ;;
  378. pgsql)
  379. # shellcheck disable=SC2154
  380. while ! echo "SELECT 1" | PGPASSWORD="${DB_PASSWORD}" psql --user="${DB_USERNAME}" --host="${DB_HOST}" "${DB_DATABASE}" > /dev/null; do
  381. staggered-sleep
  382. done
  383. ;;
  384. sqlsrv)
  385. log-warning "Don't know how to check if SQLServer is *truely* ready or not - so will just check if we're able to connect to it"
  386. # shellcheck disable=SC2154
  387. while ! timeout 1 bash -c "cat < /dev/null > /dev/tcp/${DB_HOST}/${DB_PORT}"; do
  388. staggered-sleep
  389. done
  390. ;;
  391. sqlite)
  392. log-info "${success_message_color}sqlite is always ready${color_clear}"
  393. ;;
  394. *)
  395. log-error-and-exit "Unknown database type: [${DB_CONNECTION:-}]"
  396. ;;
  397. esac
  398. log-info "${success_message_color}✅ Successfully connected to database${color_clear}"
  399. }
  400. # @description sleeps between 1 and 3 seconds to ensure a bit of randomness
  401. # in multiple scripts/containers doing work almost at the same time.
  402. function staggered-sleep()
  403. {
  404. sleep "$(get-random-number-between 1 3)"
  405. }
  406. # @description Helper function to get a random number between $1 and $2
  407. # @arg $1 int Minimum number in the range (inclusive)
  408. # @arg $2 int Maximum number in the range (inclusive)
  409. function get-random-number-between()
  410. {
  411. local -i from=${1:-1}
  412. local -i to="${2:-10}"
  413. shuf -i "${from}-${to}" -n 1
  414. }
  415. # @description Helper function to show the bask call stack when something
  416. # goes wrong. Is super useful when needing to debug an issue
  417. function show-call-stack()
  418. {
  419. local stack_size=${#FUNCNAME[@]}
  420. local func
  421. local lineno
  422. local src
  423. # to avoid noise we start with 1 to skip the get_stack function
  424. for ((i = 1; i < stack_size; i++)); do
  425. func="${FUNCNAME[$i]}"
  426. [ -z "$func" ] && func="MAIN"
  427. lineno="${BASH_LINENO[$((i - 1))]}"
  428. src="${BASH_SOURCE[$i]}"
  429. [ -z "$src" ] && src="non_file_source"
  430. log-error " at: ${func} ${src}:${lineno}"
  431. done
  432. }
  433. # @description Helper function see if $1 could be considered truthy
  434. # returns [0] if input is truthy, otherwise [1]
  435. # @arg $1 string The string to evaluate
  436. # @see as-boolean
  437. function is-true()
  438. {
  439. as-boolean "${1:-}" && return 0
  440. return 1
  441. }
  442. # @description Helper function see if $1 could be considered falsey
  443. # returns [0] if input is falsey, otherwise [1]
  444. # @arg $1 string The string to evaluate
  445. # @see as-boolean
  446. function is-false()
  447. {
  448. as-boolean "${1:-}" && return 1
  449. return 0
  450. }
  451. # @description Helper function see if $1 could be truethy or falsey.
  452. # since this is a bash context, returning 0 is true and 1 is false
  453. # so it works with [if is-false $input; then .... fi]
  454. #
  455. # This is a bit confusing, *especially* in a PHP world where [1] would be truthy and
  456. # [0] would be falsely as return values
  457. # @arg $1 string The string to evaluate
  458. function as-boolean()
  459. {
  460. local input="${1:-}"
  461. local var="${input,,}" # convert input to lower-case
  462. case "$var" in
  463. 1 | true)
  464. log-info "[as-boolean] variable [${var}] was detected as truthy/true, returning [0]"
  465. return 0
  466. ;;
  467. 0 | false)
  468. log-info "[as-boolean] variable [${var}] was detected as falsey/false, returning [1]"
  469. return 1
  470. ;;
  471. *)
  472. log-warning "[as-boolean] variable [${var}] could not be detected as true or false, returning [1] (false) as default"
  473. return 1
  474. ;;
  475. esac
  476. }