Общий binding с несколькими origin-IP больше не остаётся unmatched: строка размножается на каждый однозначно совпавший VPS. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 приватными', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user