fix(domains): привязывать общие FQDN ко всем совпавшим VPS
Docker / build (push) Failing after 29s

Общий binding с несколькими origin-IP больше не остаётся unmatched: строка размножается на каждый однозначно совпавший VPS.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-31 22:02:06 +07:00
co-authored by Cursor
parent 6e401533c7
commit 573ca097aa
7 changed files with 501 additions and 147 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
collectVpsIps, collectVpsIps,
findVpsIdByIps, findVpsIdByIps,
findVpsIdsByIps,
isPrivateOrLoopbackIp, isPrivateOrLoopbackIp,
normalizeIp, normalizeIp,
} from './ip-match.js' } from './ip-match.js'
@@ -31,6 +32,25 @@ describe('ip-match', () => {
{ id: 'b', ip: '', additionalIps: ['203.0.113.10'] }, { id: 'b', ip: '', additionalIps: ['203.0.113.10'] },
] ]
expect(findVpsIdByIps(all, ['203.0.113.10'])).toBeNull() expect(findVpsIdByIps(all, ['203.0.113.10'])).toBeNull()
expect(findVpsIdsByIps(all, ['203.0.113.10'])).toEqual([])
})
it('собирает оба VPS по разным однозначным IP', () => {
const all = [
{ id: 'a', ip: '203.0.113.10', additionalIps: [] },
{ id: 'b', ip: '203.0.113.20', additionalIps: [] },
]
expect(findVpsIdByIps(all, ['203.0.113.10', '203.0.113.20'])).toBeNull()
expect(findVpsIdsByIps(all, ['203.0.113.10', '203.0.113.20']).sort()).toEqual(['a', 'b'])
})
it('пропускает неоднозначный IP и оставляет однозначные', () => {
const all = [
{ id: 'a', ip: '203.0.113.10', additionalIps: [] },
{ id: 'b', ip: '203.0.113.20', additionalIps: ['203.0.113.10'] },
{ id: 'c', ip: '198.51.100.1', additionalIps: [] },
]
expect(findVpsIdsByIps(all, ['203.0.113.10', '198.51.100.1'])).toEqual(['c'])
}) })
it('считает loopback и RFC1918 приватными', () => { it('считает loopback и RFC1918 приватными', () => {
+16 -10
View File
@@ -36,18 +36,24 @@ export function collectVpsIps(vps: {
} }
export function findVpsIdByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string | null { export function findVpsIdByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string | null {
const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))] const ids = findVpsIdsByIps(allVps, ips)
if (normalized.length === 0) return null return ids.length === 1 ? ids[0]! : null
}
const matches: string[] = [] /**
for (const v of allVps) { * Все VPS, у которых IP из списка однозначен (этот IP есть ровно у одного сервера).
const vips = collectVpsIps(v) * IP, висящий сразу на двух VPS, пропускается.
if (normalized.some((ip) => vips.includes(ip))) { */
matches.push(v.id) export function findVpsIdsByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string[] {
} const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))]
if (normalized.length === 0) return []
const result = new Set<string>()
for (const ip of normalized) {
const matches = allVps.filter((v) => collectVpsIps(v).includes(ip))
if (matches.length === 1) result.add(matches[0]!.id)
} }
if (matches.length === 1) return matches[0]! return [...result]
return null
} }
function ipv4Octets(ip: string): number[] | null { function ipv4Octets(ip: string): number[] | null {
@@ -147,6 +147,164 @@ describe('vpsDomainsRepository', () => {
const domains = vpsDomainsRepository.listByVpsId(created.id) const domains = vpsDomainsRepository.listByVpsId(created.id)
expect(domains.map((d) => d.fqdn).sort()).toEqual(['home.rkns.top', 'mhome.rkns.top']) expect(domains.map((d) => d.fqdn).sort()).toEqual(['home.rkns.top', 'mhome.rkns.top'])
}) })
it('привязывает общий FQDN ко всем VPS с однозначными IP', () => {
const vpsA = vpsRepository.create({
ip: '203.0.113.40',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const vpsB = vpsRepository.create({
ip: '203.0.113.41',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const a = Array.isArray(vpsA) ? vpsA[0]! : vpsA
const b = Array.isArray(vpsB) ? vpsB[0]! : vpsB
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 6,
serviceId: 30,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'dns.example.com',
zoneName: 'example.com',
hostname: 'dns',
ips: ['203.0.113.40', '203.0.113.41'],
},
{
bindingId: 7,
serviceId: 30,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: '*.dns.example.com',
zoneName: 'example.com',
hostname: '*.dns',
ips: ['203.0.113.40', '203.0.113.41'],
},
])
expect(result.matched).toBe(2)
expect(result.unmatched).toBe(0)
expect(result.upserted).toBe(4)
expect(vpsDomainsRepository.listUnmatched()).toHaveLength(0)
expect(vpsDomainsRepository.listByVpsId(a.id).map((d) => d.fqdn).sort()).toEqual([
'*.dns.example.com',
'dns.example.com',
])
expect(vpsDomainsRepository.listByVpsId(b.id).map((d) => d.fqdn).sort()).toEqual([
'*.dns.example.com',
'dns.example.com',
])
})
it('привязывает только VPS с совпавшим IP, если второй IP неизвестен', () => {
const vps = vpsRepository.create({
ip: '203.0.113.50',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const created = Array.isArray(vps) ? vps[0]! : vps
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 8,
serviceId: 31,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
zoneName: 'example.com',
hostname: 'ns',
ips: ['203.0.113.50', '198.51.100.9'],
},
])
expect(result.matched).toBe(1)
expect(result.unmatched).toBe(0)
expect(vpsDomainsRepository.listByVpsId(created.id)).toHaveLength(1)
expect(vpsDomainsRepository.listUnmatched()).toHaveLength(0)
})
it('наследует все VPS родителя по CNAME', () => {
const vpsA = vpsRepository.create({
ip: '203.0.113.60',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const vpsB = vpsRepository.create({
ip: '203.0.113.61',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const a = Array.isArray(vpsA) ? vpsA[0]! : vpsA
const b = Array.isArray(vpsB) ? vpsB[0]! : vpsB
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 9,
serviceId: 40,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'dns.example.com',
zoneName: 'example.com',
hostname: 'dns',
ips: ['203.0.113.60', '203.0.113.61'],
},
{
bindingId: 10,
serviceId: 41,
serviceName: 'Alias',
serviceSlug: 'alias',
fqdn: 'ns.other.example',
zoneName: 'other.example',
hostname: 'ns',
ips: [],
cnameTarget: 'dns.example.com',
},
])
expect(result.matched).toBe(2)
expect(result.unmatched).toBe(0)
expect(vpsDomainsRepository.listByVpsId(a.id).map((d) => d.fqdn).sort()).toEqual([
'dns.example.com',
'ns.other.example',
])
expect(vpsDomainsRepository.listByVpsId(b.id).map((d) => d.fqdn).sort()).toEqual([
'dns.example.com',
'ns.other.example',
])
})
}) })
describe('settingsRepository integration fields', () => { describe('settingsRepository integration fields', () => {
+228 -115
View File
@@ -1,13 +1,13 @@
import { and, asc, eq, isNull } from 'drizzle-orm' import { and, asc, eq, isNull } from 'drizzle-orm'
// and used in markOrphaned / listUnmatched
import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm' import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm'
import { getDb, schema } from '../index.js' import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js' import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js' import { generateId } from './utils.js'
import { findVpsIdByIps, isIpLiteral } from './ip-match.js' import { findVpsIdsByIps, isIpLiteral } from './ip-match.js'
import { vpsRepository } from './vps.js' import { vpsRepository } from './vps.js'
type Row = typeof schema.vpsDomains.$inferSelect type Row = typeof schema.vpsDomains.$inferSelect
type Db = ReturnType<typeof getDb>
export type VpsDomainDto = Row export type VpsDomainDto = Row
@@ -27,33 +27,143 @@ function findVpsIdByDns(
return null return null
} }
function findVpsIdForBinding( function findVpsIdsForBinding(
allVps: ReturnType<typeof vpsRepository.list>, allVps: ReturnType<typeof vpsRepository.list>,
item: Pick<CfdmBindingSyncItem, 'ips' | 'fqdn' | 'cnameTarget'>, item: Pick<CfdmBindingSyncItem, 'ips' | 'fqdn' | 'cnameTarget'>,
): string | null { ): string[] {
return ( const byIps = findVpsIdsByIps(allVps, item.ips)
findVpsIdByIps(allVps, item.ips) ?? if (byIps.length > 0) return byIps
findVpsIdByDns(allVps, item.fqdn) ?? const byDns = findVpsIdByDns(allVps, item.fqdn)
(item.cnameTarget ? findVpsIdByDns(allVps, item.cnameTarget) : null) if (byDns) return [byDns]
) if (item.cnameTarget) {
const byCname = findVpsIdByDns(allVps, item.cnameTarget)
if (byCname) return [byCname]
}
return []
} }
function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' { function parseStoredIps(raw: string | null | undefined): string[] {
return vpsId ? 'matched' : 'unmatched' if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
} }
function applyInheritedMatch( type BindingFields = {
db: ReturnType<typeof getDb>, spaceId: string
rowId: string, fqdn: string
vpsId: string, zoneName: string
counters: { matched: number; unmatched: number }, hostname: string
): void { serviceName: string
db.update(schema.vpsDomains) serviceSlug: string
.set({ vpsId, matchStatus: 'matched' }) cfdmServiceId: number
.where(eq(schema.vpsDomains.id, rowId)) cfdmBindingId: number
source: 'cfdm'
targetIps: string
syncedAt: string
}
function listByCfdmBindingId(bindingId: number): VpsDomainDto[] {
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.all()
}
function deleteAllByCfdmBindingId(bindingId: number): boolean {
const result = getDb()
.delete(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.run() .run()
counters.matched++ return result.changes > 0
counters.unmatched = Math.max(0, counters.unmatched - 1) }
/**
* Одна unmatched-строка (vpsId NULL) либо N matched-строк — по одной на VPS.
* Лишние строки binding удаляются.
*/
function reconcileBindingToVpsIds(
db: Db,
existing: VpsDomainDto[],
fields: BindingFields,
vpsIds: string[],
emptyStatus: 'unmatched' | 'orphaned' = 'unmatched',
): { upserted: number; deleted: number } {
let upserted = 0
let deleted = 0
const keepIds = new Set<string>()
if (vpsIds.length === 0) {
const reusable = existing.find((r) => !r.vpsId) ?? existing[0]
const values = {
...fields,
vpsId: null,
matchStatus: emptyStatus,
}
if (reusable) {
db.update(schema.vpsDomains)
.set(values)
.where(eq(schema.vpsDomains.id, reusable.id))
.run()
keepIds.add(reusable.id)
upserted++
} else {
const id = generateId('vd')
db.insert(schema.vpsDomains).values({ id, ...values }).run()
keepIds.add(id)
upserted++
}
} else {
const byVpsId = new Map<string, VpsDomainDto>()
for (const row of existing) {
if (row.vpsId && !byVpsId.has(row.vpsId)) byVpsId.set(row.vpsId, row)
}
const unmatchedPool = existing.filter((r) => !r.vpsId)
for (const vpsId of vpsIds) {
const values = {
...fields,
vpsId,
matchStatus: 'matched' as const,
}
const row = byVpsId.get(vpsId) ?? unmatchedPool.shift()
if (row) {
db.update(schema.vpsDomains)
.set(values)
.where(eq(schema.vpsDomains.id, row.id))
.run()
keepIds.add(row.id)
} else {
const id = generateId('vd')
db.insert(schema.vpsDomains).values({ id, ...values }).run()
keepIds.add(id)
}
upserted++
}
}
for (const row of existing) {
if (keepIds.has(row.id)) continue
db.delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
deleted++
}
return { upserted, deleted }
}
function addToSetMap(map: Map<string, Set<string>>, key: string, ids: string[]): void {
const set = map.get(key) ?? new Set<string>()
for (const id of ids) set.add(id)
map.set(key, set)
}
function addServiceVps(map: Map<number, Set<string>>, serviceId: number, ids: string[]): void {
const set = map.get(serviceId) ?? new Set<string>()
for (const id of ids) set.add(id)
map.set(serviceId, set)
} }
export const vpsDomainsRepository = { export const vpsDomainsRepository = {
@@ -77,18 +187,11 @@ export const vpsDomainsRepository = {
}, },
getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined { getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined {
return getDb() return listByCfdmBindingId(bindingId)[0]
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.get()
}, },
deleteByCfdmBindingId(bindingId: number): boolean { deleteByCfdmBindingId(bindingId: number): boolean {
const row = this.getByCfdmBindingId(bindingId) return deleteAllByCfdmBindingId(bindingId)
if (!row) return false
getDb().delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
return true
}, },
rematchAll(): { updated: number } { rematchAll(): { updated: number } {
@@ -100,41 +203,50 @@ export const vpsDomainsRepository = {
.from(schema.vpsDomains) .from(schema.vpsDomains)
.where(eq(schema.vpsDomains.spaceId, spaceId)) .where(eq(schema.vpsDomains.spaceId, spaceId))
.all() .all()
let updated = 0 const vpsIdSet = new Set(allVps.map((v) => v.id))
const vpsIds = new Set(allVps.map((v) => v.id)) const byBinding = new Map<number, VpsDomainDto[]>()
for (const row of rows) { for (const row of rows) {
let storedIps: string[] = [] const group = byBinding.get(row.cfdmBindingId) ?? []
try { group.push(row)
storedIps = row.targetIps ? JSON.parse(row.targetIps) : [] byBinding.set(row.cfdmBindingId, group)
} catch { }
storedIps = []
}
let vpsId = row.vpsId let updated = 0
if (vpsId && !vpsIds.has(vpsId)) { const now = new Date().toISOString()
vpsId = null for (const group of byBinding.values()) {
const seed = group[0]!
const storedIps = parseStoredIps(seed.targetIps)
let wanted = storedIps.length > 0 ? findVpsIdsByIps(allVps, storedIps) : []
if (wanted.length === 0) {
const byDns = findVpsIdByDns(allVps, seed.fqdn)
if (byDns) wanted = [byDns]
} }
if (!vpsId && storedIps.length > 0) { wanted = wanted.filter((id) => vpsIdSet.has(id))
vpsId = findVpsIdByIps(allVps, storedIps)
}
if (!vpsId) {
vpsId = findVpsIdByDns(allVps, row.fqdn)
}
const matchStatus =
vpsId && vpsIds.has(vpsId)
? 'matched'
: row.vpsId && !vpsIds.has(row.vpsId)
? 'orphaned'
: resolveMatchStatus(vpsId)
if (vpsId !== row.vpsId || matchStatus !== row.matchStatus) { const hadMissingVps = group.some((r) => r.vpsId && !vpsIdSet.has(r.vpsId))
db.update(schema.vpsDomains) const emptyStatus: 'unmatched' | 'orphaned' =
.set({ vpsId, matchStatus }) wanted.length === 0 && hadMissingVps ? 'orphaned' : 'unmatched'
.where(eq(schema.vpsDomains.id, row.id))
.run() const before = new Set(group.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`))
updated++ const fields: BindingFields = {
spaceId,
fqdn: seed.fqdn,
zoneName: seed.zoneName,
hostname: seed.hostname,
serviceName: seed.serviceName,
serviceSlug: seed.serviceSlug,
cfdmServiceId: seed.cfdmServiceId,
cfdmBindingId: seed.cfdmBindingId,
source: 'cfdm',
targetIps: seed.targetIps ?? JSON.stringify(storedIps),
syncedAt: now,
} }
const result = reconcileBindingToVpsIds(db, group, fields, wanted, emptyStatus)
const afterRows = listByCfdmBindingId(seed.cfdmBindingId)
const after = new Set(afterRows.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`))
const same =
before.size === after.size && [...before].every((k) => after.has(k))
if (!same) updated += result.upserted + result.deleted
} }
return { updated } return { updated }
}, },
@@ -152,94 +264,90 @@ export const vpsDomainsRepository = {
const spaceId = getCurrentSpaceId() const spaceId = getCurrentSpaceId()
const allVps = vpsRepository.list() const allVps = vpsRepository.list()
const now = new Date().toISOString() const now = new Date().toISOString()
let matched = 0
let unmatched = 0
let deleted = 0 let deleted = 0
let upserted = 0 let upserted = 0
const keptBindingIds = new Set<number>() const keptBindingIds = new Set<number>()
const fqdnToVpsId = new Map<string, string>() const fqdnToVpsIds = new Map<string, Set<string>>()
const serviceMatchedVps = new Map<number, Set<string>>() const serviceMatchedVps = new Map<number, Set<string>>()
const counters = { matched: 0, unmatched: 0 } const counters = { matched: 0, unmatched: 0 }
const fieldsOf = (item: CfdmBindingSyncItem): BindingFields => ({
spaceId,
fqdn: item.fqdn,
zoneName: item.zoneName,
hostname: item.hostname,
serviceName: item.serviceName,
serviceSlug: item.serviceSlug,
cfdmServiceId: item.serviceId,
cfdmBindingId: item.bindingId,
source: 'cfdm',
targetIps: JSON.stringify(item.ips.filter(isIpLiteral)),
syncedAt: now,
})
for (const item of items) { for (const item of items) {
if (item.deleted) { if (item.deleted) {
if (this.deleteByCfdmBindingId(item.bindingId)) deleted++ if (deleteAllByCfdmBindingId(item.bindingId)) deleted++
continue continue
} }
keptBindingIds.add(item.bindingId) keptBindingIds.add(item.bindingId)
const vpsIds = findVpsIdsForBinding(allVps, item)
const vpsId = findVpsIdForBinding(allVps, item) if (vpsIds.length > 0) {
const matchStatus = resolveMatchStatus(vpsId)
if (matchStatus === 'matched') {
counters.matched++ counters.matched++
fqdnToVpsId.set(normalizeHost(item.fqdn), vpsId!) addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), vpsIds)
const set = serviceMatchedVps.get(item.serviceId) ?? new Set<string>() addServiceVps(serviceMatchedVps, item.serviceId, vpsIds)
set.add(vpsId!)
serviceMatchedVps.set(item.serviceId, set)
} else { } else {
counters.unmatched++ counters.unmatched++
} }
const existing = this.getByCfdmBindingId(item.bindingId) const result = reconcileBindingToVpsIds(db, listByCfdmBindingId(item.bindingId), fieldsOf(item), vpsIds)
const values = { upserted += result.upserted
spaceId, deleted += result.deleted
vpsId,
fqdn: item.fqdn,
zoneName: item.zoneName,
hostname: item.hostname,
serviceName: item.serviceName,
serviceSlug: item.serviceSlug,
cfdmServiceId: item.serviceId,
cfdmBindingId: item.bindingId,
source: 'cfdm' as const,
matchStatus,
targetIps: JSON.stringify(item.ips.filter(isIpLiteral)),
syncedAt: now,
}
if (existing) {
db.update(schema.vpsDomains).set(values).where(eq(schema.vpsDomains.id, existing.id)).run()
} else {
db.insert(schema.vpsDomains).values({ id: generateId('vd'), ...values }).run()
}
upserted++
} }
// CNAME → уже matched FQDN (например imsk → ihome)
let inherited = true let inherited = true
while (inherited) { while (inherited) {
inherited = false inherited = false
for (const item of items) { for (const item of items) {
if (item.deleted || !item.cnameTarget) continue if (item.deleted || !item.cnameTarget) continue
const existing = this.getByCfdmBindingId(item.bindingId) const existing = listByCfdmBindingId(item.bindingId)
if (!existing || existing.vpsId) continue if (existing.some((r) => r.vpsId)) continue
const parentVpsId = fqdnToVpsId.get(normalizeHost(item.cnameTarget)) const parentIds = fqdnToVpsIds.get(normalizeHost(item.cnameTarget))
if (!parentVpsId) continue if (!parentIds || parentIds.size === 0) continue
applyInheritedMatch(db, existing.id, parentVpsId, counters) const ids = [...parentIds]
fqdnToVpsId.set(normalizeHost(item.fqdn), parentVpsId) const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId)
const set = serviceMatchedVps.get(item.serviceId) ?? new Set<string>() const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), ids)
set.add(parentVpsId) upserted += result.upserted
serviceMatchedVps.set(item.serviceId, set) deleted += result.deleted
addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), ids)
addServiceVps(serviceMatchedVps, item.serviceId, ids)
if (wasUnmatched) {
counters.matched++
counters.unmatched = Math.max(0, counters.unmatched - 1)
}
inherited = true inherited = true
} }
} }
// Sibling bindings того же CFDM-сервиса → один уникальный VPS
for (const item of items) { for (const item of items) {
if (item.deleted) continue if (item.deleted) continue
const existing = this.getByCfdmBindingId(item.bindingId) const existing = listByCfdmBindingId(item.bindingId)
if (!existing || existing.vpsId) continue if (existing.some((r) => r.vpsId)) continue
const matched = serviceMatchedVps.get(item.serviceId) const matched = serviceMatchedVps.get(item.serviceId)
if (!matched || matched.size !== 1) continue if (!matched || matched.size !== 1) continue
const siblingVpsId = [...matched][0]! const siblingVpsId = [...matched][0]!
applyInheritedMatch(db, existing.id, siblingVpsId, counters) const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId)
fqdnToVpsId.set(normalizeHost(item.fqdn), siblingVpsId) const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), [siblingVpsId])
upserted += result.upserted
deleted += result.deleted
addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), [siblingVpsId])
if (wasUnmatched) {
counters.matched++
counters.unmatched = Math.max(0, counters.unmatched - 1)
}
} }
matched = counters.matched
unmatched = counters.unmatched
if (opts?.fullSync) { if (opts?.fullSync) {
const rows = db const rows = db
.select() .select()
@@ -259,7 +367,12 @@ export const vpsDomainsRepository = {
} }
} }
return { matched, unmatched, deleted, upserted } return {
matched: counters.matched,
unmatched: counters.unmatched,
deleted,
upserted,
}
}, },
markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number { markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number {
+52 -2
View File
@@ -258,11 +258,12 @@ const CORE_TABLE_MIGRATIONS: string[] = [
serviceName TEXT NOT NULL, serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL, serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL, cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL UNIQUE, cfdmBindingId INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'cfdm', source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched', matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT, targetIps TEXT,
syncedAt TEXT NOT NULL syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId)
)`, )`,
`CREATE TABLE IF NOT EXISTS vps_grants ( `CREATE TABLE IF NOT EXISTS vps_grants (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -462,6 +463,54 @@ function backfillSpaceIds(sqlite: Database.Database): void {
} }
} }
function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void {
const row = sqlite
.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'vps_domains'`)
.get() as { sql?: string } | undefined
const sql = row?.sql ?? ''
if (!sql) return
if (sql.includes('UNIQUE(cfdmBindingId, vpsId)') || sql.includes('UNIQUE (cfdmBindingId, vpsId)')) {
return
}
sqlite.exec('PRAGMA foreign_keys = OFF')
sqlite.exec('BEGIN')
try {
sqlite.exec(`CREATE TABLE vps_domains_new (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
vpsId TEXT REFERENCES vps(id) ON DELETE SET NULL,
fqdn TEXT NOT NULL,
zoneName TEXT NOT NULL,
hostname TEXT NOT NULL,
serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId)
)`)
sqlite.exec(`INSERT INTO vps_domains_new (
id, spaceId, vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt
) SELECT
id, COALESCE(spaceId, 'space-main'), vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt
FROM vps_domains`)
sqlite.exec('DROP TABLE vps_domains')
sqlite.exec('ALTER TABLE vps_domains_new RENAME TO vps_domains')
sqlite.exec('COMMIT')
} catch (err) {
sqlite.exec('ROLLBACK')
throw err
} finally {
sqlite.exec('PRAGMA foreign_keys = ON')
}
}
let migrated = false let migrated = false
export function resetRuntimeMigrate(): void { export function resetRuntimeMigrate(): void {
@@ -480,6 +529,7 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
/* column exists */ /* column exists */
} }
} }
rebuildVpsDomainsBindingUnique(sqlite)
ensureMainSpace(sqlite) ensureMainSpace(sqlite)
backfillSpaceIds(sqlite) backfillSpaceIds(sqlite)
migrated = true migrated = true
+25 -19
View File
@@ -213,25 +213,31 @@ export const settings = sqliteTable('settings', {
showQuickActions: integer('showQuickActions'), showQuickActions: integer('showQuickActions'),
}) })
export const vpsDomains = sqliteTable('vps_domains', { export const vpsDomains = sqliteTable(
id: text('id').primaryKey(), 'vps_domains',
spaceId: text('spaceId') {
.notNull() id: text('id').primaryKey(),
.default('space-main') spaceId: text('spaceId')
.references(() => spaces.id), .notNull()
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }), .default('space-main')
fqdn: text('fqdn').notNull(), .references(() => spaces.id),
zoneName: text('zoneName').notNull(), vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
hostname: text('hostname').notNull(), fqdn: text('fqdn').notNull(),
serviceName: text('serviceName').notNull(), zoneName: text('zoneName').notNull(),
serviceSlug: text('serviceSlug').notNull(), hostname: text('hostname').notNull(),
cfdmServiceId: integer('cfdmServiceId').notNull(), serviceName: text('serviceName').notNull(),
cfdmBindingId: integer('cfdmBindingId').notNull(), serviceSlug: text('serviceSlug').notNull(),
source: text('source').notNull().default('cfdm'), cfdmServiceId: integer('cfdmServiceId').notNull(),
matchStatus: text('matchStatus').notNull().default('unmatched'), cfdmBindingId: integer('cfdmBindingId').notNull(),
targetIps: text('targetIps'), source: text('source').notNull().default('cfdm'),
syncedAt: text('syncedAt').notNull(), matchStatus: text('matchStatus').notNull().default('unmatched'),
}) targetIps: text('targetIps'),
syncedAt: text('syncedAt').notNull(),
},
(t) => ({
bindingVpsUniq: uniqueIndex('vps_domains_binding_vps').on(t.cfdmBindingId, t.vpsId),
}),
)
export const notificationLog = sqliteTable('notification_log', { export const notificationLog = sqliteTable('notification_log', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
+2 -1
View File
@@ -225,11 +225,12 @@ CREATE TABLE IF NOT EXISTS vps_domains (
serviceName TEXT NOT NULL, serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL, serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL, cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL UNIQUE, cfdmBindingId INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'cfdm', source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched', matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT, targetIps TEXT,
syncedAt TEXT NOT NULL, syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId),
FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL
); );