appctl 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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. RELEASE
  42. appctl upgrade [version] stop, fetch, checkout, build, start -d --scheduler
  43. version defaults to the `latest` tag; `test` is a
  44. virtual tag resolving to origin/main HEAD; otherwise
  45. pass a release tag like v0.9.
  46. CHECKS (ephemeral containers, no running stack required)
  47. appctl lint [api|ui] composer cs (php-cs-fixer --dry-run)
  48. appctl stan [api|ui] composer stan (phpstan)
  49. appctl test [api|ui] composer test (phpunit)
  50. appctl audit [api|ui] composer audit --no-dev
  51. appctl check [api|ui] lint + stan + audit + test for the target(s)
  52. appctl ci full CI pipeline (delegates to scripts/ci.sh)
  53. OTHER
  54. appctl help show this message
  55. EOF
  56. }
  57. die() {
  58. printf 'appctl: %s\n' "$*" >&2
  59. exit 2
  60. }
  61. # --- compose helpers --------------------------------------------------
  62. # Canonical stack only (api + ui + migrate).
  63. compose_main() {
  64. docker compose -f docker-compose.yml "$@"
  65. }
  66. # Canonical stack plus the scheduler sidecar overlay. Used for start
  67. # --scheduler, build scheduler, and read-only commands (logs / ps) that
  68. # should still see the sidecar when it's running.
  69. compose_full() {
  70. docker compose -f docker-compose.yml -f compose.scheduler.yml "$@"
  71. }
  72. # Parse `--scheduler` and `-d|--detach` flags out of the args.
  73. # Sets globals: SCHED, DETACH, REST (remaining positional args).
  74. parse_stack_flags() {
  75. SCHED=0
  76. DETACH=0
  77. REST=()
  78. while (($#)); do
  79. case "$1" in
  80. --scheduler) SCHED=1 ;;
  81. -d|--detach) DETACH=1 ;;
  82. --) shift; REST+=("$@"); break ;;
  83. *) REST+=("$1") ;;
  84. esac
  85. shift
  86. done
  87. }
  88. # --- subproject helpers (echo space-separated for `for` loops) --------
  89. resolve_targets() {
  90. local arg="${1:-}"
  91. case "$arg" in
  92. "") echo "api ui" ;;
  93. api|ui) echo "$arg" ;;
  94. *) die "unknown subproject: $arg (try: api|ui)" ;;
  95. esac
  96. }
  97. # Run a command inside a one-off composer container, mounted at /app.
  98. # Mirrors scripts/ci.sh::run_php so behavior stays consistent.
  99. run_php() {
  100. local dir="$1"; shift
  101. mkdir -p "$HOME/.composer-cache"
  102. docker run --rm \
  103. -u "$UID_GID" \
  104. -v "$REPO_ROOT/$dir":/app \
  105. -v "$HOME/.composer-cache":/tmp \
  106. -w /app \
  107. -e COMPOSER_HOME=/tmp/composer \
  108. -e COMPOSER_CACHE_DIR=/tmp/composer-cache \
  109. -e XDG_CONFIG_HOME=/tmp \
  110. "$PHP_IMAGE" "$@"
  111. }
  112. # Bootstrap vendor/ on demand so tests/lint don't fail with a confusing
  113. # "vendor/bin/phpunit not found" the first time.
  114. ensure_vendor() {
  115. local dir="$1"
  116. [[ -d "$REPO_ROOT/$dir/vendor" ]] && return 0
  117. printf '[appctl] %s/vendor missing — running composer install\n' "$dir"
  118. run_php "$dir" composer install --no-interaction --prefer-dist
  119. }
  120. # --- subcommands ------------------------------------------------------
  121. cmd_start() {
  122. parse_stack_flags "$@"
  123. local up_args=(up)
  124. [[ "$DETACH" == "1" ]] && up_args+=(-d)
  125. if [[ "$SCHED" == "1" ]]; then
  126. compose_full "${up_args[@]}" "${REST[@]}"
  127. else
  128. compose_main "${up_args[@]}" "${REST[@]}"
  129. fi
  130. }
  131. cmd_stop() {
  132. parse_stack_flags "$@"
  133. # `down` operates on the project name and tears down everything
  134. # currently running, regardless of which overlays were used to
  135. # start it — but layering the scheduler overlay keeps things tidy
  136. # when --scheduler was passed.
  137. if [[ "$SCHED" == "1" ]]; then
  138. compose_full down "${REST[@]}"
  139. else
  140. compose_main down "${REST[@]}"
  141. fi
  142. }
  143. cmd_build() {
  144. local target="${1:-}"
  145. case "$target" in
  146. "") compose_main build ;;
  147. api|ui|migrate) compose_main build "$target" ;;
  148. scheduler) compose_full build scheduler ;;
  149. *) die "unknown build target: $target (try: api|ui|scheduler|migrate)" ;;
  150. esac
  151. }
  152. cmd_shell() {
  153. local service="${1:-api}"
  154. case "$service" in
  155. api|ui) ;;
  156. *) die "unknown shell target: $service (try: api|ui)" ;;
  157. esac
  158. # Both images are alpine-based today; fall back to sh if bash isn't
  159. # present so this keeps working if the base image changes.
  160. compose_main exec "$service" \
  161. sh -c 'command -v bash >/dev/null && exec bash || exec sh'
  162. }
  163. cmd_logs() {
  164. local service="${1:-}"
  165. if [[ -z "$service" ]]; then
  166. compose_full logs -f
  167. else
  168. compose_full logs -f "$service"
  169. fi
  170. }
  171. cmd_ps() {
  172. compose_full ps
  173. }
  174. cmd_migrate() {
  175. compose_main run --rm migrate
  176. }
  177. cmd_lint() {
  178. local target
  179. for target in $(resolve_targets "${1:-}"); do
  180. ensure_vendor "$target"
  181. printf '\n[appctl] %s: composer cs\n' "$target"
  182. run_php "$target" composer cs
  183. done
  184. }
  185. cmd_stan() {
  186. local target
  187. for target in $(resolve_targets "${1:-}"); do
  188. ensure_vendor "$target"
  189. printf '\n[appctl] %s: composer stan\n' "$target"
  190. run_php "$target" composer stan
  191. done
  192. }
  193. cmd_test() {
  194. local target
  195. for target in $(resolve_targets "${1:-}"); do
  196. ensure_vendor "$target"
  197. printf '\n[appctl] %s: composer test\n' "$target"
  198. run_php "$target" composer test
  199. done
  200. }
  201. cmd_audit() {
  202. local target
  203. for target in $(resolve_targets "${1:-}"); do
  204. ensure_vendor "$target"
  205. printf '\n[appctl] %s: composer audit\n' "$target"
  206. run_php "$target" composer audit --no-dev
  207. done
  208. }
  209. cmd_check() {
  210. local target="${1:-}"
  211. cmd_lint "$target"
  212. cmd_stan "$target"
  213. cmd_audit "$target"
  214. cmd_test "$target"
  215. }
  216. cmd_ci() {
  217. [[ -x scripts/ci.sh ]] || die "scripts/ci.sh missing or not executable"
  218. exec scripts/ci.sh
  219. }
  220. # Upgrade flow: fetch, resolve the requested version to a ref, take the
  221. # stack down, check out the ref (detached — tags are not branches), rebuild
  222. # all images, and bring everything back up with the scheduler sidecar in
  223. # detached mode.
  224. #
  225. # `latest` is the moving release tag (set by maintainers); `test` is a
  226. # virtual alias for origin/main HEAD so operators can stage unreleased work
  227. # without us having to push a throw-away tag for every preview.
  228. cmd_upgrade() {
  229. local version="${1:-latest}"
  230. local ref
  231. if [[ -n "$(git status --porcelain)" ]]; then
  232. die "working tree not clean — commit or stash changes before upgrading"
  233. fi
  234. printf '[appctl] fetching refs from origin\n'
  235. git fetch --all --tags --prune
  236. if [[ "$version" == "test" ]]; then
  237. ref="origin/main"
  238. elif git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then
  239. ref="refs/tags/$version"
  240. else
  241. die "unknown version: $version (try: latest, test, or a release tag like v0.9)"
  242. fi
  243. local target_sha
  244. target_sha="$(git rev-parse --short "$ref")"
  245. printf '[appctl] upgrading to %s (%s)\n' "$version" "$target_sha"
  246. printf '[appctl] stopping stack\n'
  247. compose_full down
  248. printf '[appctl] checking out %s\n' "$ref"
  249. git checkout --detach "$ref"
  250. printf '[appctl] building images\n'
  251. compose_full build
  252. printf '[appctl] starting stack (--scheduler, detached)\n'
  253. compose_full up -d
  254. }
  255. # --- dispatch ---------------------------------------------------------
  256. cmd="${1:-help}"
  257. shift || true
  258. case "$cmd" in
  259. start) cmd_start "$@" ;;
  260. stop) cmd_stop "$@" ;;
  261. build) cmd_build "$@" ;;
  262. shell) cmd_shell "$@" ;;
  263. logs) cmd_logs "$@" ;;
  264. ps) cmd_ps ;;
  265. migrate) cmd_migrate ;;
  266. lint) cmd_lint "$@" ;;
  267. stan) cmd_stan "$@" ;;
  268. test) cmd_test "$@" ;;
  269. audit) cmd_audit "$@" ;;
  270. check) cmd_check "$@" ;;
  271. ci) cmd_ci ;;
  272. upgrade) cmd_upgrade "$@" ;;
  273. help|-h|--help|"") usage ;;
  274. *) die "unknown command: $cmd (try: appctl help)" ;;
  275. esac