diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1529ed5..f523997 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,187 +1 @@ -name: CI - -on: - push: - branches: [main, dev, uat] - pull_request: - branches: [main, dev, uat] - workflow_dispatch: - inputs: - ref: - description: "Branch or ref to run CI against" - required: false - default: "main" - -jobs: - lint-typecheck: - name: Lint & Typecheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: '9.15.4' - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: | - pnpm run typecheck - pnpm --filter @groombook/db typecheck - - - name: Lint - run: pnpm run lint - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: '9.15.4' - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run tests - run: pnpm run test - - docker: - name: Build & Push Docker Images - runs-on: ubuntu-latest - needs: [lint-typecheck, test] - steps: - - uses: actions/checkout@v4 - - - name: Generate image tag - id: version - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}" - else - TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}" - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Image tag: $TAG" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver-opts: network=host - - - name: Log in to Gitea Container Registry - uses: docker/login-action@v3 - with: - registry: git.farh.net - username: ${{ gitea.actor }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Build and push API image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: runner - push: true - tags: | - git.farh.net/groombook/api:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:api - cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max - - - name: Build and push Migrate image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: migrate - push: true - tags: | - git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate - cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max - - - name: Smoke test migrate image (blackhole npmjs.org) - run: | - set -euo pipefail - IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}" - docker pull "$IMAGE" - docker run --rm \ - --add-host registry.npmjs.org:127.0.0.1 \ - --entrypoint="" \ - "$IMAGE" \ - pnpm --version - - - name: Build and push Seed image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: seed - push: true - tags: | - git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:seed - cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max - - - name: Build and push Reset image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: reset - push: true - tags: | - git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:reset - cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max - - - name: Smoke test seed image (blackhole npmjs.org) - run: | - set -euo pipefail - IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}" - docker pull "$IMAGE" - # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must - # not try to reach registry.npmjs.org on invocation. - docker run --rm \ - --add-host registry.npmjs.org:127.0.0.1 \ - --entrypoint="" \ - "$IMAGE" \ - sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version' - echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓" - - - name: Smoke test reset image (blackhole npmjs.org) - run: | - set -euo pipefail - IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}" - docker pull "$IMAGE" - # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must - # not try to reach registry.npmjs.org on invocation. Validates the - # hard requirement from the issue: reset runs offline. - docker run --rm \ - --add-host registry.npmjs.org:127.0.0.1 \ - --entrypoint="" \ - "$IMAGE" \ - sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version' - echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓" +bmFtZTogQ0kKCm9uOgogIHB1c2g6CiAgICBicmFuY2hlczogW21haW4sIGRldiwgdWF0XQogIHB1bGxfcmVxdWVzdDoKICAgIGJyYW5jaGVzOiBbbWFpbiwgZGV2LCB1YXRdCiAgd29ya2Zsb3dfZGlzcGF0Y2g6CiAgICBpbnB1dHM6CiAgICAgIHJlZjoKICAgICAgICBkZXNjcmlwdGlvbjogIkJyYW5jaCBvciByZWYgdG8gcnVuIENJIGFnYWluc3QiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgZGVmYXVsdDogIm1haW4iCgpqb2JzOgogIGxpbnQtdHlwZWNoZWNrOgogICAgbmFtZTogTGludCAmIFR5cGVjaGVjawogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgc3RlcHM6CiAgICAgIC0gdXNlczogYWN0aW9ucy9jaGVja291dEB2NAoKICAgICAgLSB1c2VzOiBwbnBtL2FjdGlvbi1zZXR1cEB2NAogICAgICAgIHdpdGg6CiAgICAgICAgICB2ZXJzaW9uOiAnOS4xNS40JwoKICAgICAgLSB1c2VzOiBhY3Rpb25zL3NldHVwLW5vZGVAdjQKICAgICAgICB3aXRoOgogICAgICAgICAgbm9kZS12ZXJzaW9uOiAyMgogICAgICAgICAgY2FjaGU6IHBucG0KCiAgICAgIC0gbmFtZTogSW5zdGFsbCBkZXBlbmRlbmNpZXMKICAgICAgICBydW46IHBucG0gaW5zdGFsbCAtLWZyb3plbi1sb2NrZmlsZQoKICAgICAgLSBuYW1lOiBUeXBlY2hlY2sKICAgICAgICBydW46IHwKICAgICAgICAgIHBucG0gcnVuIHR5cGVjaGVjawogICAgICAgICAgcG5wbSAtLWZpbHRlciBAZ3Jvb21ib29rL2RiIHR5cGVjaGVjawoKICAgICAgLSBuYW1lOiBMaW50CiAgICAgICAgcnVuOiBwbnBtIHJ1biBsaW50CgogIHRlc3Q6CiAgICBuYW1lOiBUZXN0CiAgICBydW5zLW9uOiB1YnVudHUtbGF0ZXN0CiAgICBzdGVwczoKICAgICAgLSB1c2VzOiBhY3Rpb25zL2NoZWNrb3V0QHY0CgogICAgICAtIHVzZXM6IHBucG0vYWN0aW9uLXNldHVwQHY0CiAgICAgICAgd2l0aDoKICAgICAgICAgIHZlcnNpb246ICc5LjE1LjQnCgogICAgICAtIHVzZXM6IGFjdGlvbnMvc2V0dXAtbm9kZUB2NAogICAgICAgIHdpdGg6CiAgICAgICAgICBub2RlLXZlcnNpb246IDIyCiAgICAgICAgICBjYWNoZTogcG5wbQoKICAgICAgLSBuYW1lOiBJbnN0YWxsIGRlcGVuZGVuY2llcwogICAgICAgIHJ1bjogcG5wbSBpbnN0YWxsIC0tZnJvemVuLWxvY2tmaWxlCgogICAgICAtIG5hbWU6IFJ1biB0ZXN0cwogICAgICAgIHJ1bjogcG5wbSBydW4gdGVzdAoKICBkb2NrZXI6CiAgICBuYW1lOiBCdWlsZCAmIFB1c2ggRG9ja2VyIEltYWdlcwogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgbmVlZHM6IFtsaW50LXR5cGVjaGVjaywgdGVzdF0KICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAdjQKCiAgICAgIC0gbmFtZTogR2VuZXJhdGUgaW1hZ2UgdGFnCiAgICAgICAgaWQ6IHZlcnNpb24KICAgICAgICBydW46IHwKICAgICAgICAgIGlmIFsgIiR7eyBnaXRodWIuZXZlbnRfbmFtZSB9fSIgPSAicHVsbF9yZXF1ZXN0IiBdOyB0aGVuCiAgICAgICAgICAgIFRBRz0icHItJHt7IGdpdGh1Yi5ldmVudC5wdWxsX3JlcXVlc3QubnVtYmVyIH19LSR7R0lUSFVCX1NIQTo6N30iCiAgICAgICAgICBlbHNlCiAgICAgICAgICAgIFRBRz0iJChkYXRlIC11ICslWS4lbS4lZCktJHtHSVRIVUJfU0hBOjo3fSIKICAgICAgICAgIGZpCiAgICAgICAgICBlY2hvICJ0YWc9JFRBRyIgPj4gIiRHSVRIVUJfT1VUUFVUIgogICAgICAgICAgZWNobyAiSW1hZ2UgdGFnOiAkVEFHIgoKICAgICAgLSBuYW1lOiBTZXQgdXAgRG9ja2VyIEJ1aWxkeAogICAgICAgIHVzZXM6IGRvY2tlci9zZXR1cC1idWlsZHgtYWN0aW9uQHYzCiAgICAgICAgd2l0aDoKICAgICAgICAgIGRyaXZlci1vcHRzOiBuZXR3b3JrPWhvc3QKCiAgICAgIC0gbmFtZTogTG9nIGluIHRvIEdpdGVhIENvbnRhaW5lciBSZWdpc3RyeQogICAgICAgIHVzZXM6IGRvY2tlci9sb2dpbi1hY3Rpb25AdjMKICAgICAgICB3aXRoOgogICAgICAgICAgcmVnaXN0cnk6IGdpdC5mYXJoLm5ldAogICAgICAgICAgdXNlcm5hbWU6ICR7eyBnaXRlYS5hY3RvciB9fQogICAgICAgICAgcGFzc3dvcmQ6ICR7eyBzZWNyZXRzLlJFR0lTVFJZX1RPS0VOIH19CgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoIEFQSSBpbWFnZQogICAgICAgIHVzZXM6IGRvY2tlci9idWlsZC1wdXNoLWFjdGlvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBwcm92ZW5hbmNlOiBmYWxzZQogICAgICAgICAgY29udGV4dDogLgogICAgICAgICAgZmlsZTogRG9ja2VyZmlsZQogICAgICAgICAgdGFyZ2V0OiBydW5uZXIKICAgICAgICAgIHB1c2g6IHRydWUKICAgICAgICAgIHRhZ3M6IHwKICAgICAgICAgICAgZ2l0LmZhcmgubmV0L2dyb29tYm9vay9hcGk6JHt7IHN0ZXBzLnZlcnNpb24ub3V0cHV0cy50YWcgfX0KICAgICAgICAgICAgJHt7IGdpdGh1Yi5yZWYgPT0gJ3JlZnMvaGVhZHMvbWFpbicgJiYgJ2dpdC5mYXJoLm5ldC9ncm9vbWJvb2svYXBpOmxhdGVzdCcgfHwgJycgfX0KICAgICAgICAgIGNhY2hlLWZyb206IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6YXBpCiAgICAgICAgICBjYWNoZS10bzogdHlwZT1yZWdpc3RyeSxyZWY9Z2l0LmZhcmgubmV0L2dyb29tYm9vay9jYWNoZTphcGksbW9kZT1tYXgsaWdub3JlLWVycm9yPXRydWUKCiAgICAgIC0gbmFtZTogQnVpbGQgYW5kIHB1c2ggTWlncmF0ZSBpbWFnZQogICAgICAgIHVzZXM6IGRvY2tlci9idWlsZC1wdXNoLWFjdGlvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBwcm92ZW5hbmNlOiBmYWxzZQogICAgICAgICAgY29udGV4dDogLgogICAgICAgICAgZmlsZTogRG9ja2VyZmlsZQogICAgICAgICAgdGFyZ2V0OiBtaWdyYXRlCiAgICAgICAgICBwdXNoOiB0cnVlCiAgICAgICAgICB0YWdzOiB8CiAgICAgICAgICAgIGdpdC5mYXJoLm5ldC9ncm9vbWJvb2svbWlncmF0ZToke3sgc3RlcHMudmVyc2lvbi5vdXRwdWRzLnRhZyB9fQogICAgICAgICAgICAke3sgZ2l0aHViLnJlZiA9PSAncmVmcy9oZWFkcy9tYWluJyAmJiAnZ2l0LmZhcmgubmV0L2dyb29tYm9vay9taWdyYXRlOmxhdGVzdCcgfHwgJycgfX0KICAgICAgICAgIGNhY2hlLWZyb206IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6bWlncmF0ZQogICAgICAgICAgY2FjaGUtdG86IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6bWlncmF0ZSxtb2RlPW1heCxpZ25vcmUtZXJyb3I9dHJ1ZQoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IG1pZ3JhdGUgaW1hZ2UgKGJsYWNraG9sZSBucG1qcy5vcmcpCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBzZXQgLWV1byBwaXBlZmFpbAogICAgICAgICAgSU1BR0U9ImdpdC5mYXJoLm5ldC9ncm9vbWJvb2svbWlncmF0ZToke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fSIKICAgICAgICAgIGRvY2tlciBwdWxsICIkSU1BR0UiCiAgICAgICAgICBkb2NrZXIgcnVuIC0tcm0gXAogICAgICAgICAgICAtLWFkZC1ob3N0IHJlZ2lzdHJ5Lm5wbWpzLm9yZzoxMjcuMC4wLjEgXAogICAgICAgICAgICAtLWVudHJ5cG9pbnQ9IiIgXAogICAgICAgICAgICAiJElNQUdFIiBcCiAgICAgICAgICAgIHBucG0gLS12ZXJzaW9uCgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoIFNlZWQgaW1hZ2UKICAgICAgICB1c2VzOiBkb2NrZXIvYnVpbGQtcHVzaC1hY3Rpb25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHJvdmVuYW5jZTogZmFsc2UKICAgICAgICAgIGNvbnRleHQ6IC4KICAgICAgICAgIGZpbGU6IERvY2tlcmZpbGUKICAgICAgICAgIHRhcmdldDogc2VlZAogICAgICAgICAgcHVzaDogdHJ1ZQogICAgICAgICAgdGFnczogfAogICAgICAgICAgICBnaXQuZmFyaC5uZXQvZ3Jvb21ib29rL3NlZWQ6JHt7IHN0ZXBzLnZlcnNpb24ub3V0cHV0cy50YWcgfX0KICAgICAgICAgICAgJHt7IGdpdGh1Yi5yZWYgPT0gJ3JlZnMvaGVhZHMvbWFpbicgJiYgJ2dpdC5mYXJoLm5ldC9ncm9vbWJvb2svc2VlZDpsYXRlc3QnIHx8ICcnIH19CiAgICAgICAgICBjYWNoZS1mcm9tOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnNlZWQKICAgICAgICAgIGNhY2hlLXRvOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnNlZWQsbW9kZT1tYXgsaWdub3JlLWVycm9yPXRydWUKCiAgICAgIC0gbmFtZTogQnVpbGQgYW5kIHB1c2ggUmVzZXQgaW1hZ2UKICAgICAgICB1c2VzOiBkb2NrZXIvYnVpbGQtcHVzaC1hY3Rpb25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHJvdmVuYW5jZTogZmFsc2UKICAgICAgICAgIGNvbnRleHQ6IC4KICAgICAgICAgIGZpbGU6IERvY2tlcmZpbGUKICAgICAgICAgIHRhcmdldDogcmVzZXQKICAgICAgICAgIHB1c2g6IHRydWUKICAgICAgICAgIHRhZ3M6IHwKICAgICAgICAgICAgZ2l0LmZhcmgubmV0L2dyb29tYm9vay9yZXNldDoke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fQogICAgICAgICAgICAke3sgZ2l0aHViLnJlZiA9PSAncmVmcy9oZWFkcy9tYWluJyAmJiAnZ2l0LmZhcmgubmV0L2dyb29tYm9vay9yZXNldDpsYXRlc3QnIHx8ICcnIH19CiAgICAgICAgICBjYWNoZS1mcm9tOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnJlc2V0CiAgICAgICAgICBjYWNoZS10bzogdHlwZT1yZWdpc3RyeSxyZWY9Z2l0LmZhcmgubmV0L2dyb29tYm9vay9jYWNoZTpyZXNldCxtb2RlPW1heCxpZ25vcmUtZXJyb3I9dHJ1ZQoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IHNlZWQgaW1hZ2UgKGJsYWNraG9sZSBucG1qcy5vcmcpCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBzZXQgLWV1byBwaXBlZmFpbAogICAgICAgICAgSU1BR0U9ImdpdC5mYXJoLm5ldC9ncm9vbWJvb2svc2VlZDoke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fSIKICAgICAgICAgIGRvY2tlciBwdWxsICIkSU1BR0UiCiAgICAgICAgICAjIEdSTy0xOTg1OiBwbnBtIG11c3QgYmUgYSByZWFsIGJpbmFyeSwgbm90IGEgQ29yZXBhY2sgc2hpbSwgYW5kIG11c3QKICAgICAgICAgICMgbm90IHRyeSB0byByZWFjaCByZWdpc3RyeS5ucG1qcy5vcmcgb24gaW52b2NhdGlvbi4KICAgICAgICAgIGRvY2tlciBydW4gLS1ybSBcCiAgICAgICAgICAgIC0tYWRkLWhvc3QgcmVnaXN0cnkubnBtanMub3JnOjEyNy4wLjAuMSBcCiAgICAgICAgICAgIC0tZW50cnlwb2ludD0iIiBcCiAgICAgICAgICAgICIkSU1BR0UiIFwKICAgICAgICAgICAgc2ggLWMgJ3NldCAtZTsgdGVzdCAiJCh3aGljaCBwbnBtKSIgPSAiL3Vzci9sb2NhbC9iaW4vcG5wbSI7IHBucG0gLS12ZXJzaW9uJwogICAgICAgICAgZWNobyAic2VlZCBpbWFnZTogcG5wbSByZXNvbHZlcyB0byAvdXNyL2xvY2FsL2Jpbi9wbnBtIGFuZCBydW5zIG9mZmxpbmUg4pyTIgoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IHJlc2V0IGltYWdlIChibGFja2hvbGUgbnBtanMub3JnKQogICAgICAgIHJ1bjogfAogICAgICAgICAgc2V0IC1ldW8gcGlwZWZhaWwKICAgICAgICAgIElNQUdFPSJnaXQuZmFyaC5uZXQvZ3Jvb21ib29rL3Jlc2V0OiR7eyBzdGVwcy52ZXJzaW9uLm91dHB1dHMudGFnIH19IgogICAgICAgICAgZG9ja2VyIHB1bGwgIiRJTUFHRSIKICAgICAgICAgICMgR1JPLTE5ODU6IHBucG0gbXVzdCBiZSBhIHJlYWwgYmluYXJ5LCBub3QgYSBDb3JlcGFjayBzaGltLCBhbmQgbXVzdAogICAgICAgICAgIyBub3QgdHJ5IHRvIHJlYWNoIHJlZ2lzdHJ5Lm5wbWpzLm9yZyBvbiBpbnZvY2F0aW9uLiBWYWxpZGF0ZXMgdGhlCiAgICAgICAgICAjIGhhcmQgcmVxdWlyZW1lbnQgZnJvbSB0aGUgaXNzdWU6IHJlc2V0IHJ1bnMgb2ZmbGluZS4KICAgICAgICAgIGRvY2tlciBydW4gLS1ybSBcCiAgICAgICAgICAgIC0tYWRkLWhvc3QgcmVnaXN0cnkubnBtanMub3JnOjEyNy4wLjAuMSBcCiAgICAgICAgICAgIC0tZW50cnlwb2ludD0iIiBcCiAgICAgICAgICAgICIkSU1BR0UiIFwKICAgICAgICAgICAgc2ggLWMgJ3NldCAtZTsgdGVzdCAiJCh3aGljaCBwbnBtKSIgPSAiL3Vzci9sb2NhbC9iaW4vcG5wbSI7IGVjaG8gIkhPTUU9JEhPTUUiOyBwbnBtIC0tdmVyc2lvbicKICAgICAgICAgIGVjaG8gInJlc2V0IGltYWdlOiBwbnBtIHJlc29sdmVzIHRvIC91c3IvbG9jYWwvYmluL3BucG0sIEhPTUU9L3RtcCwgcnVucyBvZmZsaW5lIOKckyIK \ No newline at end of file diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 60acc87..7b5f9d8 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -439,6 +439,22 @@ Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = fir | TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | | TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) | + +### 4.19 Boot Resilience — ECONNRESET Recovery (GRO-2652) + +Verifies the API process does not crash on transient boot-time DB connection resets and that auth routes degrade gracefully until initialization succeeds. + +| TC | Test Case | Steps | Expected Result | +|----|-----------|-------|-----------------| +| TC-API-19.1 | Health endpoint available before auth init | 1. Deploy the image (or restart the api pod)
2. `GET /health` immediately (within first 2 s of pod start) | 200 `{"status":"ok"}` — server accepts requests before `initAuth()` completes | +| TC-API-19.2 | Auth routes return 503 when auth not yet initialized | 1. Temporarily set `OIDC_ISSUER` to an unreachable host so `initAuth()` keeps retrying
2. `POST /api/auth/sign-in/email` during the retry window | 503 `{"error":"Authentication not configured"}` — process stays alive, does not exit | +| TC-API-19.3 | Pod does not crash on first-attempt DB reset | 1. Review pod restart count after normal deployment
2. Confirm `kubectl get pod -n groombook` shows `RESTARTS: 0` (or same as before deploy) for the new pod | No new restarts — ECONNRESET causes retry, not process exit | +| TC-API-19.4 | DB query retry log lines visible | After deploy, `kubectl logs -n groombook ` | If any DB retry occurred, log lines matching `[auth] DB query attempt N failed` are present; on clean boot no retry lines appear | +| TC-API-19.5 | Auth init retry log lines visible | When auth init fails and retries, check pod logs | Log lines matching `[auth] initAuth attempt N failed` present; process continues; no `process.exit` | +| TC-API-19.6 | Auth succeeds after transient DB hiccup | 1. Allow pod to retry until DB is available
2. `POST /api/auth/sign-in/email` with valid credentials after init succeeds | 200 with session cookie — auth recovers without pod restart | +| TC-API-19.7 | Normal sign-in still works end-to-end | Follow TC-WEB-SSO-3 (SSO sign-in) on UAT | Successful sign-in, staff list visible — no regression from resilience changes | +| TC-API-19.8 | Public routes unaffected during auth retry | While auth is retrying (TC-API-19.2 setup), `GET /api/branding` | 200 with branding data — public routes bypass auth and serve normally | + ## Pass/Fail Criteria **Pass:** diff --git a/src/index.ts b/src/index.ts index 6c0c930..e08a2ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -292,14 +292,34 @@ api.route("/search", searchRouter); api.route("/buffer-rules", bufferRulesRouter); api.route("/routes", routesRouter); +// Start the HTTP server first so /health and public routes are available immediately. +// Auth initialization runs afterward with retry — a transient DB ECONNRESET at boot +// must not crash the process (GRO-2652). Auth routes return 503 until initAuth succeeds. const port = Number(process.env.PORT ?? 3000); -await initAuth(); -console.log(`API server listening on port ${port}`); const server = serve({ fetch: app.fetch, port }); +console.log(`API server listening on port ${port}`); // Start background reminder scheduler (runs every minute to check for upcoming appointments) startReminderScheduler(); +let initAttempt = 0; +while (true) { + try { + await initAuth(); + break; + } catch (err) { + initAttempt++; + const delay = Math.min(2 ** initAttempt * 500, 30_000); + console.error(`[auth] initAuth attempt ${initAttempt} failed: ${err}`); + if (initAttempt >= 10) { + console.error("[auth] auth init permanently failed — auth endpoints will serve 503"); + break; + } + console.error(`[auth] retrying in ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } +} + function shutdown() { console.log("Shutting down gracefully..."); // SIGTERM/SIGINT → server.close() → callback → process.exit(0) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b28153d..9ec3520 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -124,13 +124,28 @@ export async function initAuth(): Promise { return; } - // Step 1: Try to load config from DB + // Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652). + // A single connection reset during boot must not abort initialization. const db = getDb(); - const [dbConfig] = await db - .select() - .from(authProviderConfig) - .where(eq(authProviderConfig.enabled, true)) - .limit(1); + let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = []; + let dbAttempt = 0; + while (true) { + try { + dbQueryRows = await db + .select() + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + break; + } catch (err) { + dbAttempt++; + if (dbAttempt >= 5) throw err; + const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000); + console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + } + const [dbConfig] = dbQueryRows; let providerConfig: { providerId: string;