fix(integrations): pull bindings из CFDM вместо обратного push
Docker / build (push) Failing after 19s
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:
@@ -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)
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user