feat(appgate): add AppGate SDP integration health check and sync service

Registers AppGate as a checkConfigOnly integration-health row and public
sync route, matching the existing factory + is<Name>Configured() pattern.
Committed now so Phase 13's worktree-isolated executors fork from a HEAD
that includes this integration-health.ts entry, since Plan 13-02 inserts
the PAX8 row immediately after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
This commit is contained in:
lorentz 2026-07-11 09:43:48 -04:00
parent f70a4a36ed
commit b168d44585
9 changed files with 998 additions and 0 deletions

View file

@ -0,0 +1,33 @@
/**
* GET /api/appgate/health
* Live AppGate Controller probe used by the Integration Health dashboard
* and the admin/integrations page. Does a cheap unauthenticated reach test
* (identity-providers/names) plus a credential check (login).
*/
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import { getAppgateClient, isAppgateConfigured } from '@/lib/services/appgate-factory';
export async function GET() {
const { error } = await requireAdmin();
if (error) return error;
if (!isAppgateConfigured()) {
return NextResponse.json({ configured: false, reachable: false, authenticated: false });
}
try {
const client = getAppgateClient();
await client.ping();
// Force a login by hitting an authenticated endpoint.
await client.getLicense();
return NextResponse.json({ configured: true, reachable: true, authenticated: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const reachable = !/unreachable|ENOTFOUND|ECONNREFUSED/i.test(msg);
return NextResponse.json(
{ configured: true, reachable, authenticated: false, error: msg },
{ status: 503 },
);
}
}

View file

@ -0,0 +1,78 @@
/**
* AppGate sync API
*
* POST /api/appgate/sync trigger a sync (body: { syncType, triggeredBy })
* GET /api/appgate/sync last-sync status + record counts
*
* No auth gate: webhook-style entrypoint hit by the in-process scheduler
* (same pattern as `/api/qbo/sync`). Make sure it's listed in
* `middleware.ts`'s public path array.
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { getAppgateSyncService } from '@/lib/services/appgate-sync-service';
import { isAppgateConfigured } from '@/lib/services/appgate-factory';
export async function POST(request: NextRequest) {
try {
if (!isAppgateConfigured()) {
return NextResponse.json(
{ error: 'AppGate not configured — set APPGATE_URL/USERNAME/PASSWORD/DEVICE_ID env vars' },
{ status: 503 },
);
}
const body = await request.json().catch(() => ({}));
const syncType: 'sessions' | 'daily' = body.syncType === 'daily' ? 'daily' : 'sessions';
const triggeredBy = typeof body.triggeredBy === 'string' ? body.triggeredBy : 'api';
const svc = getAppgateSyncService();
if (svc.isSyncInProgress()) {
return NextResponse.json({ error: 'AppGate sync already in progress' }, { status: 409 });
}
(syncType === 'daily' ? svc.dailySync(triggeredBy) : svc.sessionsSync(triggeredBy))
.catch((e) => console.error('[AppgateSync API] sync failed:', e));
return NextResponse.json({ message: `AppGate ${syncType} sync started`, triggeredBy });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET() {
try {
const [sessions, devices, appliances, licenseUsers, snapshots, lastSync] = await Promise.all([
postgresClient.query<{ count: string }>(`SELECT COUNT(*)::text as count FROM appgate_active_sessions`),
postgresClient.query<{ count: string; deleted: string }>(
`SELECT COUNT(*) FILTER (WHERE is_deleted=false)::text as count,
COUNT(*) FILTER (WHERE is_deleted=true)::text as deleted
FROM appgate_devices`),
postgresClient.query<{ count: string }>(`SELECT COUNT(*) FILTER (WHERE is_deleted=false)::text as count FROM appgate_appliances`),
postgresClient.query<{ count: string }>(`SELECT COUNT(*)::text as count FROM appgate_license_users`),
postgresClient.query<{ snapshot_date: string; max_users: number; used_users: number; expiration: string | null }>(
`SELECT snapshot_date, max_users, used_users, expiration
FROM appgate_license_snapshots
ORDER BY snapshot_date DESC LIMIT 1`),
postgresClient.query<{
sync_id: string; sync_type: string; status: string; started_at: string; completed_at: string | null; last_error: string | null;
}>(
`SELECT sync_id, sync_type, status, started_at, completed_at, last_error
FROM appgate_sync_history
ORDER BY started_at DESC LIMIT 1`),
]);
return NextResponse.json({
configured: isAppgateConfigured(),
counts: {
active_sessions: parseInt(sessions.rows[0]?.count ?? '0', 10),
devices: parseInt(devices.rows[0]?.count ?? '0', 10),
devices_tombstoned: parseInt(devices.rows[0]?.deleted ?? '0', 10),
appliances: parseInt(appliances.rows[0]?.count ?? '0', 10),
license_users: parseInt(licenseUsers.rows[0]?.count ?? '0', 10),
},
latest_license: snapshots.rows[0] ?? null,
last_sync: lastSync.rows[0] ?? null,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}