feat: Payload CMS robustness - auto-detect migrations, deep health check, improved error messages

- cms-sync.sh: auto-detect migrations from src/migrations/*.ts (no manual list)
- cms-sync.sh: pre-flight container checks with actionable error messages
- api/health/cms: deep health check that queries all Payload collections
- deploy.yml: auto-detect migrations in sanitization step
- deploy.yml: CMS deep health smoke test in post-deploy
This commit is contained in:
2026-02-27 02:36:59 +01:00
parent 0be885428d
commit cadb104917
3 changed files with 116 additions and 26 deletions

View File

@@ -385,20 +385,29 @@ jobs:
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
DO \\\$\\\$ BEGIN
DELETE FROM payload_migrations WHERE batch = -1;
INSERT INTO payload_migrations (name, batch)
SELECT name, batch FROM (VALUES
('20260223_195005_products_collection', 1),
('20260223_195151_remove_sku_unique', 2),
('20260225_003500_add_pages_collection', 3)
) AS v(name, batch)
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
EXCEPTION WHEN undefined_table THEN
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
END \\\$\\\$;
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
# Auto-detect migrations from src/migrations/*.ts
BATCH=1
VALUES=""
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
NAME=$(basename "$f" .ts)
[ -n "$VALUES" ] && VALUES="$VALUES,"
VALUES="$VALUES ('$NAME', $BATCH)"
((BATCH++))
done
if [ -n "$VALUES" ]; then
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
DO \\\$\\\$ BEGIN
DELETE FROM payload_migrations WHERE batch = -1;
INSERT INTO payload_migrations (name, batch)
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
EXCEPTION WHEN undefined_table THEN
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
END \\\$\\\$;
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
fi
# Restart app to pick up clean migration state
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
@@ -474,6 +483,26 @@ jobs:
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
- name: 🏥 CMS Deep Health Check
env:
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: |
echo "Waiting 10s for app to fully start..."
sleep 10
echo "Checking basic health..."
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
echo "✅ Basic health OK"
echo "Checking CMS DB connectivity..."
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
echo "❌ CMS health check failed!"
echo "$RESPONSE"
echo ""
echo "This usually means Payload CMS migrations failed or DB tables are missing."
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
exit 1
}
echo "✅ CMS health: $RESPONSE"
- name: 🚀 OG Image Check
if: always() && steps.deps.outcome == 'success'
env:

View File

@@ -1,9 +1,41 @@
import { NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
export const dynamic = 'force-dynamic';
/**
* Deep CMS Health Check
* Validates that Payload CMS can actually query the database.
* Used by post-deploy smoke tests to catch migration/schema issues.
*/
export async function GET() {
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
// Further DB health checks can be implemented via Payload Local API later.
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
const checks: Record<string, string> = {};
try {
const payload = await getPayload({ config: configPromise });
checks.init = 'ok';
// Verify each collection can be queried (catches missing locale tables, broken migrations)
const collections = ['posts', 'products', 'pages', 'media'] as const;
for (const collection of collections) {
try {
await payload.find({ collection, limit: 1, locale: 'en' });
checks[collection] = 'ok';
} catch (e: any) {
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
}
}
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
return NextResponse.json(
{ status: hasErrors ? 'degraded' : 'ok', checks },
{ status: hasErrors ? 503 : 200 },
);
} catch (e: any) {
return NextResponse.json(
{ status: 'error', message: e.message?.substring(0, 200), checks },
{ status: 503 },
);
}
}

View File

@@ -53,13 +53,17 @@ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
REMOTE_DB_USER=""
REMOTE_DB_NAME=""
# Migration names to insert after restore (keeps Payload from prompting)
MIGRATIONS=(
"20260223_195005_products_collection:1"
"20260223_195151_remove_sku_unique:2"
"20260225_003500_add_pages_collection:3"
"20260225_175000_native_localization:4"
)
# Auto-detect migrations from src/migrations/*.ts (no manual maintenance needed)
MIGRATIONS=()
BATCH=1
for migration_file in $(ls src/migrations/*.ts 2>/dev/null | sort); do
name=$(basename "$migration_file" .ts)
MIGRATIONS+=("$name:$BATCH")
((BATCH++))
done
if [ ${#MIGRATIONS[@]} -eq 0 ]; then
echo "⚠️ No migration files found in src/migrations/"
fi
# ── Resolve target environment ─────────────────────────────────────────────
resolve_target() {
@@ -159,6 +163,29 @@ backup_remote_db() {
REMOTE_BACKUP_FILE="$file"
}
# ── Pre-flight: Verify remote containers exist ─────────────────────────────
check_remote_containers() {
echo "🔍 Checking $TARGET containers..."
local missing=0
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then
echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST"
echo " → Deploy $TARGET first: git push to trigger pipeline, or run:"
echo " ssh $SSH_HOST \"cd $REMOTE_SITE_DIR && docker compose -p $REMOTE_PROJECT --env-file .env.\$TARGET up -d\""
missing=1
fi
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then
echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST"
missing=1
fi
if [ $missing -eq 1 ]; then
echo ""
echo "💡 The $TARGET environment hasn't been deployed yet."
echo " Push to the '$TARGET' branch or run the pipeline first."
exit 1
fi
echo "✅ All $TARGET containers running."
}
# ── PUSH: local → remote ──────────────────────────────────────────────────
do_push() {
echo ""
@@ -172,8 +199,9 @@ do_push() {
echo ""
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
# 0. Ensure local DB is running
# 0. Ensure local DB is running & remote containers exist
ensure_local_db
check_remote_containers
# 1. Safety backup of remote
backup_remote_db
@@ -227,8 +255,9 @@ do_pull() {
echo ""
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
# 0. Ensure local DB is running
# 0. Ensure local DB is running & remote containers exist
ensure_local_db
check_remote_containers
# 1. Safety backup of local
backup_local_db