Общий binding с несколькими origin-IP больше не остаётся unmatched: строка размножается на каждый однозначно совпавший VPS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectVpsIps,
|
||||
findVpsIdByIps,
|
||||
findVpsIdsByIps,
|
||||
isPrivateOrLoopbackIp,
|
||||
normalizeIp,
|
||||
} from './ip-match.js'
|
||||
@@ -31,6 +32,25 @@ describe('ip-match', () => {
|
||||
{ id: 'b', ip: '', additionalIps: ['203.0.113.10'] },
|
||||
]
|
||||
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 приватными', () => {
|
||||
|
||||
@@ -36,18 +36,24 @@ export function collectVpsIps(vps: {
|
||||
}
|
||||
|
||||
export function findVpsIdByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string | null {
|
||||
const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))]
|
||||
if (normalized.length === 0) return null
|
||||
const ids = findVpsIdsByIps(allVps, ips)
|
||||
return ids.length === 1 ? ids[0]! : null
|
||||
}
|
||||
|
||||
const matches: string[] = []
|
||||
for (const v of allVps) {
|
||||
const vips = collectVpsIps(v)
|
||||
if (normalized.some((ip) => vips.includes(ip))) {
|
||||
matches.push(v.id)
|
||||
}
|
||||
/**
|
||||
* Все VPS, у которых IP из списка однозначен (этот IP есть ровно у одного сервера).
|
||||
* IP, висящий сразу на двух VPS, пропускается.
|
||||
*/
|
||||
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 null
|
||||
return [...result]
|
||||
}
|
||||
|
||||
function ipv4Octets(ip: string): number[] | null {
|
||||
|
||||
@@ -147,6 +147,164 @@ describe('vpsDomainsRepository', () => {
|
||||
const domains = vpsDomainsRepository.listByVpsId(created.id)
|
||||
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', () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm'
|
||||
// and used in markOrphaned / listUnmatched
|
||||
import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm'
|
||||
import { getDb, schema } from '../index.js'
|
||||
import { getCurrentSpaceId } from '../space-context.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'
|
||||
|
||||
type Row = typeof schema.vpsDomains.$inferSelect
|
||||
type Db = ReturnType<typeof getDb>
|
||||
|
||||
export type VpsDomainDto = Row
|
||||
|
||||
@@ -27,33 +27,143 @@ function findVpsIdByDns(
|
||||
return null
|
||||
}
|
||||
|
||||
function findVpsIdForBinding(
|
||||
function findVpsIdsForBinding(
|
||||
allVps: ReturnType<typeof vpsRepository.list>,
|
||||
item: Pick<CfdmBindingSyncItem, 'ips' | 'fqdn' | 'cnameTarget'>,
|
||||
): string | null {
|
||||
return (
|
||||
findVpsIdByIps(allVps, item.ips) ??
|
||||
findVpsIdByDns(allVps, item.fqdn) ??
|
||||
(item.cnameTarget ? findVpsIdByDns(allVps, item.cnameTarget) : null)
|
||||
)
|
||||
): string[] {
|
||||
const byIps = findVpsIdsByIps(allVps, item.ips)
|
||||
if (byIps.length > 0) return byIps
|
||||
const byDns = findVpsIdByDns(allVps, item.fqdn)
|
||||
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' {
|
||||
return vpsId ? 'matched' : 'unmatched'
|
||||
function parseStoredIps(raw: string | null | undefined): string[] {
|
||||
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(
|
||||
db: ReturnType<typeof getDb>,
|
||||
rowId: string,
|
||||
vpsId: string,
|
||||
counters: { matched: number; unmatched: number },
|
||||
): void {
|
||||
db.update(schema.vpsDomains)
|
||||
.set({ vpsId, matchStatus: 'matched' })
|
||||
.where(eq(schema.vpsDomains.id, rowId))
|
||||
type BindingFields = {
|
||||
spaceId: string
|
||||
fqdn: string
|
||||
zoneName: string
|
||||
hostname: string
|
||||
serviceName: string
|
||||
serviceSlug: string
|
||||
cfdmServiceId: number
|
||||
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()
|
||||
counters.matched++
|
||||
counters.unmatched = Math.max(0, counters.unmatched - 1)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна 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 = {
|
||||
@@ -77,18 +187,11 @@ export const vpsDomainsRepository = {
|
||||
},
|
||||
|
||||
getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(schema.vpsDomains)
|
||||
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
|
||||
.get()
|
||||
return listByCfdmBindingId(bindingId)[0]
|
||||
},
|
||||
|
||||
deleteByCfdmBindingId(bindingId: number): boolean {
|
||||
const row = this.getByCfdmBindingId(bindingId)
|
||||
if (!row) return false
|
||||
getDb().delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
|
||||
return true
|
||||
return deleteAllByCfdmBindingId(bindingId)
|
||||
},
|
||||
|
||||
rematchAll(): { updated: number } {
|
||||
@@ -100,41 +203,50 @@ export const vpsDomainsRepository = {
|
||||
.from(schema.vpsDomains)
|
||||
.where(eq(schema.vpsDomains.spaceId, spaceId))
|
||||
.all()
|
||||
let updated = 0
|
||||
const vpsIds = new Set(allVps.map((v) => v.id))
|
||||
|
||||
const vpsIdSet = new Set(allVps.map((v) => v.id))
|
||||
const byBinding = new Map<number, VpsDomainDto[]>()
|
||||
for (const row of rows) {
|
||||
let storedIps: string[] = []
|
||||
try {
|
||||
storedIps = row.targetIps ? JSON.parse(row.targetIps) : []
|
||||
} catch {
|
||||
storedIps = []
|
||||
}
|
||||
const group = byBinding.get(row.cfdmBindingId) ?? []
|
||||
group.push(row)
|
||||
byBinding.set(row.cfdmBindingId, group)
|
||||
}
|
||||
|
||||
let vpsId = row.vpsId
|
||||
if (vpsId && !vpsIds.has(vpsId)) {
|
||||
vpsId = null
|
||||
let updated = 0
|
||||
const now = new Date().toISOString()
|
||||
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) {
|
||||
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)
|
||||
wanted = wanted.filter((id) => vpsIdSet.has(id))
|
||||
|
||||
if (vpsId !== row.vpsId || matchStatus !== row.matchStatus) {
|
||||
db.update(schema.vpsDomains)
|
||||
.set({ vpsId, matchStatus })
|
||||
.where(eq(schema.vpsDomains.id, row.id))
|
||||
.run()
|
||||
updated++
|
||||
const hadMissingVps = group.some((r) => r.vpsId && !vpsIdSet.has(r.vpsId))
|
||||
const emptyStatus: 'unmatched' | 'orphaned' =
|
||||
wanted.length === 0 && hadMissingVps ? 'orphaned' : 'unmatched'
|
||||
|
||||
const before = new Set(group.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`))
|
||||
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 }
|
||||
},
|
||||
@@ -152,94 +264,90 @@ export const vpsDomainsRepository = {
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const allVps = vpsRepository.list()
|
||||
const now = new Date().toISOString()
|
||||
let matched = 0
|
||||
let unmatched = 0
|
||||
let deleted = 0
|
||||
let upserted = 0
|
||||
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 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) {
|
||||
if (item.deleted) {
|
||||
if (this.deleteByCfdmBindingId(item.bindingId)) deleted++
|
||||
if (deleteAllByCfdmBindingId(item.bindingId)) deleted++
|
||||
continue
|
||||
}
|
||||
|
||||
keptBindingIds.add(item.bindingId)
|
||||
|
||||
const vpsId = findVpsIdForBinding(allVps, item)
|
||||
const matchStatus = resolveMatchStatus(vpsId)
|
||||
if (matchStatus === 'matched') {
|
||||
const vpsIds = findVpsIdsForBinding(allVps, item)
|
||||
if (vpsIds.length > 0) {
|
||||
counters.matched++
|
||||
fqdnToVpsId.set(normalizeHost(item.fqdn), vpsId!)
|
||||
const set = serviceMatchedVps.get(item.serviceId) ?? new Set<string>()
|
||||
set.add(vpsId!)
|
||||
serviceMatchedVps.set(item.serviceId, set)
|
||||
addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), vpsIds)
|
||||
addServiceVps(serviceMatchedVps, item.serviceId, vpsIds)
|
||||
} else {
|
||||
counters.unmatched++
|
||||
}
|
||||
|
||||
const existing = this.getByCfdmBindingId(item.bindingId)
|
||||
const values = {
|
||||
spaceId,
|
||||
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++
|
||||
const result = reconcileBindingToVpsIds(db, listByCfdmBindingId(item.bindingId), fieldsOf(item), vpsIds)
|
||||
upserted += result.upserted
|
||||
deleted += result.deleted
|
||||
}
|
||||
|
||||
// CNAME → уже matched FQDN (например imsk → ihome)
|
||||
let inherited = true
|
||||
while (inherited) {
|
||||
inherited = false
|
||||
for (const item of items) {
|
||||
if (item.deleted || !item.cnameTarget) continue
|
||||
const existing = this.getByCfdmBindingId(item.bindingId)
|
||||
if (!existing || existing.vpsId) continue
|
||||
const parentVpsId = fqdnToVpsId.get(normalizeHost(item.cnameTarget))
|
||||
if (!parentVpsId) continue
|
||||
applyInheritedMatch(db, existing.id, parentVpsId, counters)
|
||||
fqdnToVpsId.set(normalizeHost(item.fqdn), parentVpsId)
|
||||
const set = serviceMatchedVps.get(item.serviceId) ?? new Set<string>()
|
||||
set.add(parentVpsId)
|
||||
serviceMatchedVps.set(item.serviceId, set)
|
||||
const existing = listByCfdmBindingId(item.bindingId)
|
||||
if (existing.some((r) => r.vpsId)) continue
|
||||
const parentIds = fqdnToVpsIds.get(normalizeHost(item.cnameTarget))
|
||||
if (!parentIds || parentIds.size === 0) continue
|
||||
const ids = [...parentIds]
|
||||
const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId)
|
||||
const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), ids)
|
||||
upserted += result.upserted
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Sibling bindings того же CFDM-сервиса → один уникальный VPS
|
||||
for (const item of items) {
|
||||
if (item.deleted) continue
|
||||
const existing = this.getByCfdmBindingId(item.bindingId)
|
||||
if (!existing || existing.vpsId) continue
|
||||
const existing = listByCfdmBindingId(item.bindingId)
|
||||
if (existing.some((r) => r.vpsId)) continue
|
||||
const matched = serviceMatchedVps.get(item.serviceId)
|
||||
if (!matched || matched.size !== 1) continue
|
||||
const siblingVpsId = [...matched][0]!
|
||||
applyInheritedMatch(db, existing.id, siblingVpsId, counters)
|
||||
fqdnToVpsId.set(normalizeHost(item.fqdn), siblingVpsId)
|
||||
const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId)
|
||||
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) {
|
||||
const rows = db
|
||||
.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 {
|
||||
|
||||
@@ -258,11 +258,12 @@ const CORE_TABLE_MIGRATIONS: string[] = [
|
||||
serviceName TEXT NOT NULL,
|
||||
serviceSlug TEXT NOT NULL,
|
||||
cfdmServiceId INTEGER NOT NULL,
|
||||
cfdmBindingId INTEGER NOT NULL UNIQUE,
|
||||
cfdmBindingId INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'cfdm',
|
||||
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
|
||||
targetIps TEXT,
|
||||
syncedAt TEXT NOT NULL
|
||||
syncedAt TEXT NOT NULL,
|
||||
UNIQUE(cfdmBindingId, vpsId)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS vps_grants (
|
||||
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
|
||||
|
||||
export function resetRuntimeMigrate(): void {
|
||||
@@ -480,6 +529,7 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||
/* column exists */
|
||||
}
|
||||
}
|
||||
rebuildVpsDomainsBindingUnique(sqlite)
|
||||
ensureMainSpace(sqlite)
|
||||
backfillSpaceIds(sqlite)
|
||||
migrated = true
|
||||
|
||||
@@ -213,25 +213,31 @@ export const settings = sqliteTable('settings', {
|
||||
showQuickActions: integer('showQuickActions'),
|
||||
})
|
||||
|
||||
export const vpsDomains = sqliteTable('vps_domains', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('spaceId')
|
||||
.notNull()
|
||||
.default('space-main')
|
||||
.references(() => spaces.id),
|
||||
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
|
||||
fqdn: text('fqdn').notNull(),
|
||||
zoneName: text('zoneName').notNull(),
|
||||
hostname: text('hostname').notNull(),
|
||||
serviceName: text('serviceName').notNull(),
|
||||
serviceSlug: text('serviceSlug').notNull(),
|
||||
cfdmServiceId: integer('cfdmServiceId').notNull(),
|
||||
cfdmBindingId: integer('cfdmBindingId').notNull(),
|
||||
source: text('source').notNull().default('cfdm'),
|
||||
matchStatus: text('matchStatus').notNull().default('unmatched'),
|
||||
targetIps: text('targetIps'),
|
||||
syncedAt: text('syncedAt').notNull(),
|
||||
})
|
||||
export const vpsDomains = sqliteTable(
|
||||
'vps_domains',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('spaceId')
|
||||
.notNull()
|
||||
.default('space-main')
|
||||
.references(() => spaces.id),
|
||||
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
|
||||
fqdn: text('fqdn').notNull(),
|
||||
zoneName: text('zoneName').notNull(),
|
||||
hostname: text('hostname').notNull(),
|
||||
serviceName: text('serviceName').notNull(),
|
||||
serviceSlug: text('serviceSlug').notNull(),
|
||||
cfdmServiceId: integer('cfdmServiceId').notNull(),
|
||||
cfdmBindingId: integer('cfdmBindingId').notNull(),
|
||||
source: text('source').notNull().default('cfdm'),
|
||||
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', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
@@ -225,11 +225,12 @@ CREATE TABLE IF NOT EXISTS vps_domains (
|
||||
serviceName TEXT NOT NULL,
|
||||
serviceSlug TEXT NOT NULL,
|
||||
cfdmServiceId INTEGER NOT NULL,
|
||||
cfdmBindingId INTEGER NOT NULL UNIQUE,
|
||||
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),
|
||||
FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user