fix(integrations): pull bindings из CFDM вместо обратного push
Docker / build (push) Failing after 19s

Устраняет fetch failed при ручном sync, когда CFDM не может достучаться до VPS Tracker. Ошибки показывают URL; UI подсказывает URL из App Switcher.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-01 00:36:04 +07:00
co-authored by Cursor
parent fd2d5f39c0
commit c5198cdd7f
4 changed files with 139 additions and 15 deletions
+56 -4
View File
@@ -115,12 +115,31 @@ describe('settings cfdm sync', () => {
}) })
it('requests full sync from CFDM', async () => { it('requests full sync from CFDM', async () => {
const fetchMock = vi.fn(async () => Response.json({ ok: true, count: 3 })) const fetchMock = vi.fn(async () =>
Response.json({
ok: true,
count: 1,
fullSync: true,
bindings: [
{
bindingId: 1,
serviceId: 10,
serviceName: 'web',
serviceSlug: 'web',
fqdn: 'app.example.com',
zoneName: 'example.com',
hostname: 'app',
ips: ['1.2.3.4'],
},
],
}),
)
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' })
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true, count: 3 }) expect(res.json()).toMatchObject({ ok: true })
expect((res.json() as { count: number }).count).toBeGreaterThanOrEqual(1)
const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined
expect(call?.[0]).toBe('http://cfdm.test/api/v1/integrations/vps-tracker/sync') expect(call?.[0]).toBe('http://cfdm.test/api/v1/integrations/vps-tracker/sync')
@@ -135,20 +154,53 @@ describe('settings cfdm sync', () => {
integrationToken: 'shared-token', integrationToken: 'shared-token',
cfdmApiUrl: 'http://cfdm.test', cfdmApiUrl: 'http://cfdm.test',
}) })
const fetchMock = vi.fn(async () => Response.json({ ok: true, count: 1 })) const fetchMock = vi.fn(async () =>
Response.json({ ok: true, count: 0, bindings: [], fullSync: true }),
)
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' })
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true, count: 1 }) expect(res.json()).toEqual({ ok: true, count: 0 })
expect(fetchMock).toHaveBeenCalled() expect(fetchMock).toHaveBeenCalled()
}) })
it('returns clear error when CFDM is unreachable', async () => {
settingsRepository.upsert('settings-main', {
integrationEnabled: true,
integrationToken: 'shared-token',
cfdmApiUrl: 'http://cfdm.test',
})
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed')
}),
)
const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' })
expect(res.statusCode).toBe(502)
const body = res.json() as { ok: boolean; error: string }
expect(body.ok).toBe(false)
expect(body.error).toContain('http://cfdm.test')
expect(body.error).toContain('fetch failed')
})
it('returns error when CFDM URL is missing', async () => { it('returns error when CFDM URL is missing', async () => {
settingsRepository.upsert('settings-main', { settingsRepository.upsert('settings-main', {
integrationEnabled: true, integrationEnabled: true,
integrationToken: 'shared-token', integrationToken: 'shared-token',
cfdmApiUrl: '', cfdmApiUrl: '',
appSwitcher: {
menuLabel: 'Apps',
apps: [
{
id: 'vps-tracker',
name: 'VPS Tracker',
url: 'http://127.0.0.1:3001',
},
],
},
}) })
const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' })
expect(res.statusCode).toBe(502) expect(res.statusCode).toBe(502)
+70 -6
View File
@@ -1,12 +1,39 @@
import { settingsRepository } from '@cfdm/db/repositories/settings' import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import {
cfdmSyncBindingsBodySchema,
type CfdmBindingSyncItem,
} from '@cfdm/shared/contracts/integration-cfdm'
function isUsableHttpUrl(raw: string): boolean {
try {
const u = new URL(raw)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
const host = u.hostname.toLowerCase()
if (host === 'example.com' || host.endsWith('.example.com')) return false
return true
} catch {
return false
}
}
function resolveCfdmApiBase(): string | null { function resolveCfdmApiBase(): string | null {
const row = settingsRepository.getBySpace() const row = settingsRepository.getBySpace()
if (!row) return null if (!row) return null
const explicit = row.cfdmApiUrl?.trim() const explicit = row.cfdmApiUrl?.trim()
if (explicit) return explicit.replace(/\/$/, '') if (explicit && isUsableHttpUrl(explicit)) return explicit.replace(/\/$/, '')
const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm') const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm')
return cfdm?.url?.trim().replace(/\/$/, '') ?? null const fromSwitcher = cfdm?.url?.trim().replace(/\/$/, '') ?? ''
if (fromSwitcher && isUsableHttpUrl(fromSwitcher)) return fromSwitcher
return null
}
function networkErrorMessage(baseUrl: string, err: unknown): string {
const raw = err instanceof Error ? err.message : 'Ошибка сети'
return (
`Не удалось подключиться к CFDM (${baseUrl}): ${raw}. ` +
'Укажите URL API CFDM (доступный с хоста VPS Tracker API) и сохраните.'
)
} }
export async function requestCfdmFullSync(): Promise<{ export async function requestCfdmFullSync(): Promise<{
@@ -16,30 +43,67 @@ export async function requestCfdmFullSync(): Promise<{
}> { }> {
const token = settingsRepository.getIntegrationToken() const token = settingsRepository.getIntegrationToken()
const baseUrl = resolveCfdmApiBase() const baseUrl = resolveCfdmApiBase()
if (!baseUrl) return { ok: false, error: 'Укажите URL API CFDM' } if (!baseUrl) {
return {
ok: false,
error:
'Укажите URL API CFDM в настройках интеграции (или добавьте приложение cfdm в App Switcher)',
}
}
if (!token) return { ok: false, error: 'Укажите integration token' } if (!token) return { ok: false, error: 'Укажите integration token' }
const syncUrl = `${baseUrl}/api/v1/integrations/vps-tracker/sync`
try { try {
const res = await fetch(`${baseUrl}/api/v1/integrations/vps-tracker/sync`, { const res = await fetch(syncUrl, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(60_000),
}) })
const body = (await res.json().catch(() => ({}))) as { const body = (await res.json().catch(() => ({}))) as {
ok?: boolean ok?: boolean
count?: number count?: number
error?: string error?: string
bindings?: CfdmBindingSyncItem[]
fullSync?: boolean
} }
if (!res.ok || body.ok === false) { if (!res.ok || body.ok === false) {
return { return {
ok: false, ok: false,
error: body.error ?? `HTTP ${res.status}`, error: body.error ?? `CFDM HTTP ${res.status} (${syncUrl})`,
} }
} }
// Pull: CFDM отдаёт bindings в ответе — применяем локально (без обратного push).
if (Array.isArray(body.bindings)) {
const parsed = cfdmSyncBindingsBodySchema.safeParse({
bindings: body.bindings,
fullSync: body.fullSync !== false,
})
if (!parsed.success) {
return {
ok: false,
error: `Некорректный ответ CFDM: ${parsed.error.message}`,
}
}
const applied = vpsDomainsRepository.syncBindings(parsed.data.bindings, {
fullSync: parsed.data.fullSync === true,
})
settingsRepository.touchIntegrationSync()
return {
ok: true,
count: parsed.data.bindings.length || applied.upserted,
}
}
// Legacy: CFDM уже запушил bindings сам и вернул только count.
settingsRepository.touchIntegrationSync()
return { ok: true, count: body.count ?? 0 } return { ok: true, count: body.count ?? 0 }
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
error: err instanceof Error ? err.message : 'Ошибка сети', error: networkErrorMessage(baseUrl, err),
} }
} }
} }
@@ -31,6 +31,8 @@ function generateToken(): string {
interface CfdmIntegrationFormProps { interface CfdmIntegrationFormProps {
settings?: Settings settings?: Settings
/** URL CFDM из App Switcher — подсказка и fallback, если cfdmApiUrl не сохранён */
fallbackCfdmUrl?: string
onSave: (values: { onSave: (values: {
cfdmApiUrl?: string cfdmApiUrl?: string
integrationToken?: string integrationToken?: string
@@ -42,6 +44,7 @@ interface CfdmIntegrationFormProps {
/** CFDM integration form — Frame/SettingRow. Preview https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-16 */ /** CFDM integration form — Frame/SettingRow. Preview https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-16 */
export function CfdmIntegrationForm({ export function CfdmIntegrationForm({
settings, settings,
fallbackCfdmUrl,
onSave, onSave,
isSaving, isSaving,
}: CfdmIntegrationFormProps) { }: CfdmIntegrationFormProps) {
@@ -79,9 +82,9 @@ export function CfdmIntegrationForm({
}) })
} }
// Sync по сохранённому токену (URL — cfdmApiUrl или App Switcher на API).
const hasSavedToken = Boolean(settings?.integrationTokenSet) const hasSavedToken = Boolean(settings?.integrationTokenSet)
const hasSavedUrl = Boolean(settings?.cfdmApiUrl?.trim()) const hasSavedUrl = Boolean(settings?.cfdmApiUrl?.trim())
const switcherUrl = fallbackCfdmUrl?.trim() || ''
const canSync = const canSync =
hasSavedToken || Boolean(settings?.integrationLastSyncAt?.trim()) hasSavedToken || Boolean(settings?.integrationLastSyncAt?.trim())
@@ -93,7 +96,7 @@ export function CfdmIntegrationForm({
<FieldGroup className="gap-0"> <FieldGroup className="gap-0">
<SettingRow <SettingRow
title="Принимать синхронизацию" title="Принимать синхронизацию"
description="Разрешить CFDM пушить домены и сервисы" description="Разрешить CFDM пушить домены и сервисы (авто-sync). Ручная кнопка работает и без этого."
> >
<Controller <Controller
control={form.control} control={form.control}
@@ -111,8 +114,10 @@ export function CfdmIntegrationForm({
title="URL API CFDM" title="URL API CFDM"
description={ description={
hasSavedUrl hasSavedUrl
? 'Сохранён — для failover vps_down и ручного sync' ? 'Сохранён — ручной sync и failover vps_down'
: 'Не сохранён: укажите URL и нажмите «Сохранить», либо настройте CFDM в App Switcher' : switcherUrl
? `Не сохранён — sync пойдёт на App Switcher: ${switcherUrl}`
: 'Укажите URL API CFDM (например http://192.168.x.x:6363) и сохраните'
} }
labelFor="cfdm-api-url" labelFor="cfdm-api-url"
stacked stacked
@@ -120,7 +125,7 @@ export function CfdmIntegrationForm({
<Input <Input
id="cfdm-api-url" id="cfdm-api-url"
className="w-full" className="w-full"
placeholder="https://cfdm.example.com" placeholder={switcherUrl || 'http://192.168.100.67:6363'}
{...form.register('cfdmApiUrl')} {...form.register('cfdmApiUrl')}
/> />
</SettingRow> </SettingRow>
@@ -256,6 +256,9 @@ function SettingsIntegrationsPage() {
> >
<CfdmIntegrationForm <CfdmIntegrationForm
settings={current} settings={current}
fallbackCfdmUrl={
appSwitcher.apps.find((a) => a.id === 'cfdm')?.url
}
isSaving={saveMut.isPending} isSaving={saveMut.isPending}
onSave={(values) => saveMut.mutate(values)} onSave={(values) => saveMut.mutate(values)}
/> />