diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2aaea12..4e59173 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -98,13 +98,19 @@ jobs: - name: Configure GHCR pull + deploy backend image shell: bash env: - GHCR_PASSWORD: ${{ secrets.GHCR_READ_TOKEN != '' && secrets.GHCR_READ_TOKEN || secrets.GITHUB_TOKEN }} + GHCR_PASSWORD: ${{ secrets.GHCR_READ_TOKEN }} run: | + set -euo pipefail + if [[ -z "${GHCR_PASSWORD}" ]]; then + echo "::error::GHCR_READ_TOKEN secret is required. GITHUB_TOKEN expires when CD ends — Azure cannot pull images on scale-from-zero without a PAT (read:packages) or public GHCR packages." + exit 1 + fi + az containerapp registry set \ --name "$BACKEND_APP_NAME" \ --resource-group "$AZURE_RESOURCE_GROUP" \ --server ghcr.io \ - --username "${{ github.actor }}" \ + --username "${{ github.repository_owner }}" \ --password "$GHCR_PASSWORD" az containerapp update \ @@ -232,13 +238,24 @@ jobs: - name: Configure GHCR pull + deploy frontend image shell: bash env: - GHCR_PASSWORD: ${{ secrets.GHCR_READ_TOKEN != '' && secrets.GHCR_READ_TOKEN || secrets.GITHUB_TOKEN }} + GHCR_PASSWORD: ${{ secrets.GHCR_READ_TOKEN }} + BACKEND_URL: ${{ needs.deploy-backend.outputs.backend_url }} run: | + set -euo pipefail + if [[ -z "${GHCR_PASSWORD}" ]]; then + echo "::error::GHCR_READ_TOKEN secret is required. GITHUB_TOKEN expires when CD ends — Azure cannot pull images on scale-from-zero without a PAT (read:packages) or public GHCR packages." + exit 1 + fi + if [[ -z "${BACKEND_URL}" || "${BACKEND_URL}" == "https://" ]]; then + echo "::error::backend_url job output is empty — cannot set BACKEND_UPSTREAM" + exit 1 + fi + az containerapp registry set \ --name "$FRONTEND_APP_NAME" \ --resource-group "$AZURE_RESOURCE_GROUP" \ --server ghcr.io \ - --username "${{ github.actor }}" \ + --username "${{ github.repository_owner }}" \ --password "$GHCR_PASSWORD" az containerapp update \ @@ -247,7 +264,7 @@ jobs: --image "$FRONTEND_IMAGE" \ --min-replicas 0 \ --max-replicas 2 \ - --set-env-vars "BUILD_SHA=${{ github.sha }},BACKEND_UPSTREAM=${{ needs.deploy-backend.outputs.backend_url }}" \ + --set-env-vars "BUILD_SHA=${{ github.sha }}" "BACKEND_UPSTREAM=${BACKEND_URL}" \ --output table - name: Get frontend URL diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7cc846c..ce7c7ff 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -66,8 +66,8 @@ export async function googleSignIn(idToken: string): Promise { } /** GET /api/auth/me — current user profile + query budget */ -export async function getMe(): Promise { - return apiFetch("/api/auth/me"); +export async function getMe(signal?: AbortSignal): Promise { + return apiFetch("/api/auth/me", { signal }); } /** POST /api/auth/logout */ diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index fc260d4..5e74da7 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -28,8 +28,11 @@ const ProtectedRoute: React.FC = ({ children }) => { bg-gray-50 dark:bg-gray-950">
+ rounded-full animate-spin motion-reduce:animate-none" />

Loading InsightIQ…

+

+ Waking from idle can take up to a minute. +

); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 8945196..9bddf2f 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -70,13 +70,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ return; } + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 45_000); + try { - const profile = await getMe(); + const profile = await getMe(controller.signal); setUser(profile); } catch { - // Token expired or invalid — clear it + // Token expired, backend asleep, or wake-up timed out — show login localStorage.removeItem(TOKEN_KEY); } finally { + window.clearTimeout(timeoutId); setIsLoading(false); } }; diff --git a/infra/fix-scale-to-zero.sh b/infra/fix-scale-to-zero.sh index 148033c..0d3b2b7 100755 --- a/infra/fix-scale-to-zero.sh +++ b/infra/fix-scale-to-zero.sh @@ -27,6 +27,22 @@ echo " Backend app : $BACKEND_APP" echo " Frontend app : $FRONTEND_APP" echo "" +check_pull_errors() { + local app="$1" + local revision + revision=$(az containerapp show --name "$app" --resource-group "$RESOURCE_GROUP" \ + --query "properties.latestRevisionName" -o tsv) + local details + details=$(az containerapp replica list --name "$app" --resource-group "$RESOURCE_GROUP" \ + --revision "$revision" -o json 2>/dev/null \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print(r[0]['properties']['containers'][0].get('runningStateDetails','')) if r else print('')" 2>/dev/null || true) + if [[ "$details" == *ImagePullBackOff* ]]; then + echo " ⚠ ${app}: ${details}" + return 1 + fi + return 0 +} + for app in "$BACKEND_APP" "$FRONTEND_APP"; do echo "--- $app ---" az containerapp show \ @@ -38,29 +54,30 @@ for app in "$BACKEND_APP" "$FRONTEND_APP"; do az containerapp revision list \ --name "$app" \ --resource-group "$RESOURCE_GROUP" \ - --query "[].{name:name,active:properties.active,traffic:properties.trafficWeight,health:properties.healthState,replicas:properties.replicas}" \ + --query "[].{name:name,active:properties.active,traffic:properties.trafficWeight,health:properties.healthState,replicas:properties.replicas,running:properties.runningState}" \ -o table + check_pull_errors "$app" || PULL_ERROR=1 echo "" done -echo "[1/3] Enforcing min-replicas=0 (idle cost) on both apps..." -az containerapp update --name "$BACKEND_APP" --resource-group "$RESOURCE_GROUP" --min-replicas 0 --output none -az containerapp update --name "$FRONTEND_APP" --resource-group "$RESOURCE_GROUP" --min-replicas 0 --output none -echo " ✓ min-replicas=0" - -echo "" -echo "[2/3] Checklist before redeploy:" -echo " • GitHub Packages → insightiq-frontend / insightiq-backend → Package settings → Public" -echo " (or set GHCR_READ_TOKEN secret with read:packages scope)" -echo " • GitHub Secrets: NEON_DATABASE_URL, JWT_SECRET_KEY, ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID" -echo " • GitHub → Actions → CD → Run workflow on main" +echo "==================================================" +echo " ROOT CAUSE (most common): ImagePullBackOff" +echo "==================================================" +echo " Azure stores GHCR credentials to pull container images." +echo " If CD used GITHUB_TOKEN, it EXPIRES when the workflow ends." +echo " Scale-from-zero then cannot pull → Activation failed → hang forever." echo "" -echo "[3/3] After CD succeeds, test wake-up (first click may take 30–90s):" -BACKEND_FQDN=$(az containerapp show --name "$BACKEND_APP" --resource-group "$RESOURCE_GROUP" --query "properties.configuration.ingress.fqdn" -o tsv) -FRONTEND_FQDN=$(az containerapp show --name "$FRONTEND_APP" --resource-group "$RESOURCE_GROUP" --query "properties.configuration.ingress.fqdn" -o tsv) -echo " Frontend : https://${FRONTEND_FQDN}/" -echo " Backend : https://${BACKEND_FQDN}/health" +echo " FIX (pick one):" +echo " A) GitHub → Packages → insightiq-frontend + insightiq-backend" +echo " → Package settings → Change visibility to PUBLIC" +echo " B) GitHub → Settings → Developer settings → PAT (classic)" +echo " → read:packages → add as repo secret GHCR_READ_TOKEN" +echo " → re-run CD workflow on main" echo "" -echo "If a revision still shows 'Activation failed' with 100% traffic," -echo "open Portal → Revisions → deactivate the failed revision, then re-run CD." +echo " After fixing GHCR access, re-run CD (Actions → CD → Run workflow)." +echo " First visit after idle: allow 30–90s cold start." echo "==================================================" + +if [[ "${PULL_ERROR:-0}" == 1 ]]; then + exit 1 +fi