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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
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 {
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 приватными', () => {
+16 -10
View File
@@ -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', () => {
+228 -115
View File
@@ -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 {
+52 -2
View File
@@ -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
+25 -19
View File
@@ -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(),
+2 -1
View File
@@ -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
);