appctl 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. #!/usr/bin/env bash
  2. # appctl — convenience wrapper for the irdb docker compose stack and CI checks.
  3. #
  4. # The canonical stack is api + ui + migrate (docker-compose.yml). The
  5. # scheduler sidecar (compose.scheduler.yml) is opt-in via --scheduler.
  6. #
  7. # Per-subproject scope is accepted as an optional positional arg on the
  8. # check commands (lint/stan/test/audit/check). With no arg, the command
  9. # runs against both api and ui — the same coverage as scripts/ci.sh.
  10. #
  11. # Lint, stan, test, audit and check all run in ephemeral composer/node
  12. # containers (mirroring scripts/ci.sh) so the host needs no PHP or Node
  13. # toolchain installed.
  14. set -euo pipefail
  15. # Resolve repo root regardless of where the user invokes us from. Works
  16. # whether called as bin/appctl or via the top-level ./appctl symlink.
  17. SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
  18. SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
  19. if [[ "$(basename "$SCRIPT_DIR")" == "bin" ]]; then
  20. REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
  21. else
  22. REPO_ROOT="$SCRIPT_DIR"
  23. fi
  24. cd "$REPO_ROOT"
  25. PHP_IMAGE="composer:2"
  26. NODE_IMAGE="node:20-alpine"
  27. UID_GID="$(id -u):$(id -g)"
  28. usage() {
  29. cat <<'EOF'
  30. appctl — irdb docker compose / CI wrapper
  31. USAGE
  32. appctl <command> [args]
  33. STACK (docker compose; canonical stack is api + ui + migrate)
  34. appctl start [--scheduler] [-d] bring the stack up (foreground; -d to detach)
  35. appctl stop [--scheduler] stop and remove containers
  36. appctl build [api|ui|scheduler] build images (no arg = api + ui)
  37. appctl shell [api|ui] bash into a running container (default: api)
  38. appctl logs [service] follow logs (no arg = all services)
  39. appctl ps list stack status (incl. scheduler if running)
  40. appctl migrate run the one-shot migrate container
  41. CHECKS (ephemeral containers, no running stack required)
  42. appctl lint [api|ui] composer cs (php-cs-fixer --dry-run)
  43. appctl stan [api|ui] composer stan (phpstan)
  44. appctl test [api|ui] composer test (phpunit)
  45. appctl audit [api|ui] composer audit --no-dev
  46. appctl check [api|ui] lint + stan + audit + test for the target(s)
  47. appctl ci full CI pipeline (delegates to scripts/ci.sh)
  48. OTHER
  49. appctl help show this message
  50. EOF
  51. }
  52. die() {
  53. printf 'appctl: %s\n' "$*" >&2
  54. exit 2
  55. }
  56. # --- compose helpers --------------------------------------------------
  57. # Canonical stack only (api + ui + migrate).
  58. compose_main() {
  59. docker compose -f docker-compose.yml "$@"
  60. }
  61. # Canonical stack plus the scheduler sidecar overlay. Used for start
  62. # --scheduler, build scheduler, and read-only commands (logs / ps) that
  63. # should still see the sidecar when it's running.
  64. compose_full() {
  65. docker compose -f docker-compose.yml -f compose.scheduler.yml "$@"
  66. }
  67. # Parse `--scheduler` and `-d|--detach` flags out of the args.
  68. # Sets globals: SCHED, DETACH, REST (remaining positional args).
  69. parse_stack_flags() {
  70. SCHED=0
  71. DETACH=0
  72. REST=()
  73. while (($#)); do
  74. case "$1" in
  75. --scheduler) SCHED=1 ;;
  76. -d|--detach) DETACH=1 ;;
  77. --) shift; REST+=("$@"); break ;;
  78. *) REST+=("$1") ;;
  79. esac
  80. shift
  81. done
  82. }
  83. # --- subproject helpers (echo space-separated for `for` loops) --------
  84. resolve_targets() {
  85. local arg="${1:-}"
  86. case "$arg" in
  87. "") echo "api ui" ;;
  88. api|ui) echo "$arg" ;;
  89. *) die "unknown subproject: $arg (try: api|ui)" ;;
  90. esac
  91. }
  92. # Run a command inside a one-off composer container, mounted at /app.
  93. # Mirrors scripts/ci.sh::run_php so behavior stays consistent.
  94. run_php() {
  95. local dir="$1"; shift
  96. mkdir -p "$HOME/.composer-cache"
  97. docker run --rm \
  98. -u "$UID_GID" \
  99. -v "$REPO_ROOT/$dir":/app \
  100. -v "$HOME/.composer-cache":/tmp \
  101. -w /app \
  102. -e COMPOSER_HOME=/tmp/composer \
  103. -e COMPOSER_CACHE_DIR=/tmp/composer-cache \
  104. -e XDG_CONFIG_HOME=/tmp \
  105. "$PHP_IMAGE" "$@"
  106. }
  107. # Bootstrap vendor/ on demand so tests/lint don't fail with a confusing
  108. # "vendor/bin/phpunit not found" the first time.
  109. ensure_vendor() {
  110. local dir="$1"
  111. [[ -d "$REPO_ROOT/$dir/vendor" ]] && return 0
  112. printf '[appctl] %s/vendor missing — running composer install\n' "$dir"
  113. run_php "$dir" composer install --no-interaction --prefer-dist
  114. }
  115. # --- subcommands ------------------------------------------------------
  116. cmd_start() {
  117. parse_stack_flags "$@"
  118. local up_args=(up)
  119. [[ "$DETACH" == "1" ]] && up_args+=(-d)
  120. if [[ "$SCHED" == "1" ]]; then
  121. compose_full "${up_args[@]}" "${REST[@]}"
  122. else
  123. compose_main "${up_args[@]}" "${REST[@]}"
  124. fi
  125. }
  126. cmd_stop() {
  127. parse_stack_flags "$@"
  128. # `down` operates on the project name and tears down everything
  129. # currently running, regardless of which overlays were used to
  130. # start it — but layering the scheduler overlay keeps things tidy
  131. # when --scheduler was passed.
  132. if [[ "$SCHED" == "1" ]]; then
  133. compose_full down "${REST[@]}"
  134. else
  135. compose_main down "${REST[@]}"
  136. fi
  137. }
  138. cmd_build() {
  139. local target="${1:-}"
  140. case "$target" in
  141. "") compose_main build ;;
  142. api|ui|migrate) compose_main build "$target" ;;
  143. scheduler) compose_full build scheduler ;;
  144. *) die "unknown build target: $target (try: api|ui|scheduler|migrate)" ;;
  145. esac
  146. }
  147. cmd_shell() {
  148. local service="${1:-api}"
  149. case "$service" in
  150. api|ui) ;;
  151. *) die "unknown shell target: $service (try: api|ui)" ;;
  152. esac
  153. # Both images are alpine-based today; fall back to sh if bash isn't
  154. # present so this keeps working if the base image changes.
  155. compose_main exec "$service" \
  156. sh -c 'command -v bash >/dev/null && exec bash || exec sh'
  157. }
  158. cmd_logs() {
  159. local service="${1:-}"
  160. if [[ -z "$service" ]]; then
  161. compose_full logs -f
  162. else
  163. compose_full logs -f "$service"
  164. fi
  165. }
  166. cmd_ps() {
  167. compose_full ps
  168. }
  169. cmd_migrate() {
  170. compose_main run --rm migrate
  171. }
  172. cmd_lint() {
  173. local target
  174. for target in $(resolve_targets "${1:-}"); do
  175. ensure_vendor "$target"
  176. printf '\n[appctl] %s: composer cs\n' "$target"
  177. run_php "$target" composer cs
  178. done
  179. }
  180. cmd_stan() {
  181. local target
  182. for target in $(resolve_targets "${1:-}"); do
  183. ensure_vendor "$target"
  184. printf '\n[appctl] %s: composer stan\n' "$target"
  185. run_php "$target" composer stan
  186. done
  187. }
  188. cmd_test() {
  189. local target
  190. for target in $(resolve_targets "${1:-}"); do
  191. ensure_vendor "$target"
  192. printf '\n[appctl] %s: composer test\n' "$target"
  193. run_php "$target" composer test
  194. done
  195. }
  196. cmd_audit() {
  197. local target
  198. for target in $(resolve_targets "${1:-}"); do
  199. ensure_vendor "$target"
  200. printf '\n[appctl] %s: composer audit\n' "$target"
  201. run_php "$target" composer audit --no-dev
  202. done
  203. }
  204. cmd_check() {
  205. local target="${1:-}"
  206. cmd_lint "$target"
  207. cmd_stan "$target"
  208. cmd_audit "$target"
  209. cmd_test "$target"
  210. }
  211. cmd_ci() {
  212. [[ -x scripts/ci.sh ]] || die "scripts/ci.sh missing or not executable"
  213. exec scripts/ci.sh
  214. }
  215. # --- dispatch ---------------------------------------------------------
  216. cmd="${1:-help}"
  217. shift || true
  218. case "$cmd" in
  219. start) cmd_start "$@" ;;
  220. stop) cmd_stop "$@" ;;
  221. build) cmd_build "$@" ;;
  222. shell) cmd_shell "$@" ;;
  223. logs) cmd_logs "$@" ;;
  224. ps) cmd_ps ;;
  225. migrate) cmd_migrate ;;
  226. lint) cmd_lint "$@" ;;
  227. stan) cmd_stan "$@" ;;
  228. test) cmd_test "$@" ;;
  229. audit) cmd_audit "$@" ;;
  230. check) cmd_check "$@" ;;
  231. ci) cmd_ci ;;
  232. help|-h|--help|"") usage ;;
  233. *) die "unknown command: $cmd (try: appctl help)" ;;
  234. esac