| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- #!/usr/bin/env bash
- # appctl — convenience wrapper for the irdb docker compose stack and CI checks.
- #
- # The canonical stack is api + ui + migrate (docker-compose.yml). The
- # scheduler sidecar (compose.scheduler.yml) is opt-in via --scheduler.
- #
- # Per-subproject scope is accepted as an optional positional arg on the
- # check commands (lint/stan/test/audit/check). With no arg, the command
- # runs against both api and ui — the same coverage as scripts/ci.sh.
- #
- # Lint, stan, test, audit and check all run in ephemeral composer/node
- # containers (mirroring scripts/ci.sh) so the host needs no PHP or Node
- # toolchain installed.
- set -euo pipefail
- # Resolve repo root regardless of where the user invokes us from. Works
- # whether called as bin/appctl or via the top-level ./appctl symlink.
- SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
- SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
- if [[ "$(basename "$SCRIPT_DIR")" == "bin" ]]; then
- REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
- else
- REPO_ROOT="$SCRIPT_DIR"
- fi
- cd "$REPO_ROOT"
- PHP_IMAGE="composer:2"
- NODE_IMAGE="node:20-alpine"
- UID_GID="$(id -u):$(id -g)"
- usage() {
- cat <<'EOF'
- appctl — irdb docker compose / CI wrapper
- USAGE
- appctl <command> [args]
- STACK (docker compose; canonical stack is api + ui + migrate)
- appctl start [--scheduler] [-d] bring the stack up (foreground; -d to detach)
- appctl stop [--scheduler] stop and remove containers
- appctl build [api|ui|scheduler] build images (no arg = api + ui)
- appctl shell [api|ui] bash into a running container (default: api)
- appctl logs [service] follow logs (no arg = all services)
- appctl ps list stack status (incl. scheduler if running)
- appctl migrate run the one-shot migrate container
- RELEASE
- appctl upgrade [version] stop, fetch, checkout, build, start -d --scheduler
- version defaults to the `latest` tag; `test` is a
- virtual tag resolving to origin/main HEAD; otherwise
- pass a release tag like v0.9.
- CHECKS (ephemeral containers, no running stack required)
- appctl lint [api|ui] composer cs (php-cs-fixer --dry-run)
- appctl stan [api|ui] composer stan (phpstan)
- appctl test [api|ui] composer test (phpunit)
- appctl audit [api|ui] composer audit --no-dev
- appctl check [api|ui] lint + stan + audit + test for the target(s)
- appctl ci full CI pipeline (delegates to scripts/ci.sh)
- OTHER
- appctl help show this message
- EOF
- }
- die() {
- printf 'appctl: %s\n' "$*" >&2
- exit 2
- }
- # --- compose helpers --------------------------------------------------
- # Canonical stack only (api + ui + migrate).
- compose_main() {
- docker compose -f docker-compose.yml "$@"
- }
- # Canonical stack plus the scheduler sidecar overlay. Used for start
- # --scheduler, build scheduler, and read-only commands (logs / ps) that
- # should still see the sidecar when it's running.
- compose_full() {
- docker compose -f docker-compose.yml -f compose.scheduler.yml "$@"
- }
- # Parse `--scheduler` and `-d|--detach` flags out of the args.
- # Sets globals: SCHED, DETACH, REST (remaining positional args).
- parse_stack_flags() {
- SCHED=0
- DETACH=0
- REST=()
- while (($#)); do
- case "$1" in
- --scheduler) SCHED=1 ;;
- -d|--detach) DETACH=1 ;;
- --) shift; REST+=("$@"); break ;;
- *) REST+=("$1") ;;
- esac
- shift
- done
- }
- # --- subproject helpers (echo space-separated for `for` loops) --------
- resolve_targets() {
- local arg="${1:-}"
- case "$arg" in
- "") echo "api ui" ;;
- api|ui) echo "$arg" ;;
- *) die "unknown subproject: $arg (try: api|ui)" ;;
- esac
- }
- # Run a command inside a one-off composer container, mounted at /app.
- # Mirrors scripts/ci.sh::run_php so behavior stays consistent.
- run_php() {
- local dir="$1"; shift
- mkdir -p "$HOME/.composer-cache"
- docker run --rm \
- -u "$UID_GID" \
- -v "$REPO_ROOT/$dir":/app \
- -v "$HOME/.composer-cache":/tmp \
- -w /app \
- -e COMPOSER_HOME=/tmp/composer \
- -e COMPOSER_CACHE_DIR=/tmp/composer-cache \
- -e XDG_CONFIG_HOME=/tmp \
- "$PHP_IMAGE" "$@"
- }
- # Bootstrap vendor/ on demand so tests/lint don't fail with a confusing
- # "vendor/bin/phpunit not found" the first time.
- ensure_vendor() {
- local dir="$1"
- [[ -d "$REPO_ROOT/$dir/vendor" ]] && return 0
- printf '[appctl] %s/vendor missing — running composer install\n' "$dir"
- run_php "$dir" composer install --no-interaction --prefer-dist
- }
- # --- subcommands ------------------------------------------------------
- cmd_start() {
- parse_stack_flags "$@"
- local up_args=(up)
- [[ "$DETACH" == "1" ]] && up_args+=(-d)
- if [[ "$SCHED" == "1" ]]; then
- compose_full "${up_args[@]}" "${REST[@]}"
- else
- compose_main "${up_args[@]}" "${REST[@]}"
- fi
- }
- cmd_stop() {
- parse_stack_flags "$@"
- # `down` operates on the project name and tears down everything
- # currently running, regardless of which overlays were used to
- # start it — but layering the scheduler overlay keeps things tidy
- # when --scheduler was passed.
- if [[ "$SCHED" == "1" ]]; then
- compose_full down "${REST[@]}"
- else
- compose_main down "${REST[@]}"
- fi
- }
- cmd_build() {
- local target="${1:-}"
- case "$target" in
- "") compose_main build ;;
- api|ui|migrate) compose_main build "$target" ;;
- scheduler) compose_full build scheduler ;;
- *) die "unknown build target: $target (try: api|ui|scheduler|migrate)" ;;
- esac
- }
- cmd_shell() {
- local service="${1:-api}"
- case "$service" in
- api|ui) ;;
- *) die "unknown shell target: $service (try: api|ui)" ;;
- esac
- # Both images are alpine-based today; fall back to sh if bash isn't
- # present so this keeps working if the base image changes.
- compose_main exec "$service" \
- sh -c 'command -v bash >/dev/null && exec bash || exec sh'
- }
- cmd_logs() {
- local service="${1:-}"
- if [[ -z "$service" ]]; then
- compose_full logs -f
- else
- compose_full logs -f "$service"
- fi
- }
- cmd_ps() {
- compose_full ps
- }
- cmd_migrate() {
- compose_main run --rm migrate
- }
- cmd_lint() {
- local target
- for target in $(resolve_targets "${1:-}"); do
- ensure_vendor "$target"
- printf '\n[appctl] %s: composer cs\n' "$target"
- run_php "$target" composer cs
- done
- }
- cmd_stan() {
- local target
- for target in $(resolve_targets "${1:-}"); do
- ensure_vendor "$target"
- printf '\n[appctl] %s: composer stan\n' "$target"
- run_php "$target" composer stan
- done
- }
- cmd_test() {
- local target
- for target in $(resolve_targets "${1:-}"); do
- ensure_vendor "$target"
- printf '\n[appctl] %s: composer test\n' "$target"
- run_php "$target" composer test
- done
- }
- cmd_audit() {
- local target
- for target in $(resolve_targets "${1:-}"); do
- ensure_vendor "$target"
- printf '\n[appctl] %s: composer audit\n' "$target"
- run_php "$target" composer audit --no-dev
- done
- }
- cmd_check() {
- local target="${1:-}"
- cmd_lint "$target"
- cmd_stan "$target"
- cmd_audit "$target"
- cmd_test "$target"
- }
- cmd_ci() {
- [[ -x scripts/ci.sh ]] || die "scripts/ci.sh missing or not executable"
- exec scripts/ci.sh
- }
- # Upgrade flow: fetch, resolve the requested version to a ref, take the
- # stack down, check out the ref (detached — tags are not branches), rebuild
- # all images, and bring everything back up with the scheduler sidecar in
- # detached mode.
- #
- # `latest` is the moving release tag (set by maintainers); `test` is a
- # virtual alias for origin/main HEAD so operators can stage unreleased work
- # without us having to push a throw-away tag for every preview.
- cmd_upgrade() {
- local version="${1:-latest}"
- local ref
- if [[ -n "$(git status --porcelain)" ]]; then
- die "working tree not clean — commit or stash changes before upgrading"
- fi
- printf '[appctl] fetching refs from origin\n'
- git fetch --all --tags --prune
- if [[ "$version" == "test" ]]; then
- ref="origin/main"
- elif git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then
- ref="refs/tags/$version"
- else
- die "unknown version: $version (try: latest, test, or a release tag like v0.9)"
- fi
- local target_sha
- target_sha="$(git rev-parse --short "$ref")"
- printf '[appctl] upgrading to %s (%s)\n' "$version" "$target_sha"
- printf '[appctl] stopping stack\n'
- compose_full down
- printf '[appctl] checking out %s\n' "$ref"
- git checkout --detach "$ref"
- printf '[appctl] building images\n'
- compose_full build
- printf '[appctl] starting stack (--scheduler, detached)\n'
- compose_full up -d
- }
- # --- dispatch ---------------------------------------------------------
- cmd="${1:-help}"
- shift || true
- case "$cmd" in
- start) cmd_start "$@" ;;
- stop) cmd_stop "$@" ;;
- build) cmd_build "$@" ;;
- shell) cmd_shell "$@" ;;
- logs) cmd_logs "$@" ;;
- ps) cmd_ps ;;
- migrate) cmd_migrate ;;
- lint) cmd_lint "$@" ;;
- stan) cmd_stan "$@" ;;
- test) cmd_test "$@" ;;
- audit) cmd_audit "$@" ;;
- check) cmd_check "$@" ;;
- ci) cmd_ci ;;
- upgrade) cmd_upgrade "$@" ;;
- help|-h|--help|"") usage ;;
- *) die "unknown command: $cmd (try: appctl help)" ;;
- esac
|