fix(veesp): обновление IP, характеристик и валюты при повторном синке
Docker / build (push) Failing after 21s

Перезапись vcpu/ram/disk/ip на update, fetch VM ips, приоритет валюты из balance API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 12:10:47 +07:00
co-authored by Cursor
parent 5983c911f2
commit ce6414c65f
7 changed files with 185 additions and 17 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { resolveProviderCurrency } from '@cfdm/shared/utils/currency'
import { effectiveAccountBalanceCurrency } from '@cfdm/shared/utils/account-balance'
import { effectiveAccountBalanceCurrency, syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
describe('resolveProviderCurrency', () => {
it('prefers account currency over provider baseCurrency', () => {
@@ -12,6 +12,26 @@ describe('resolveProviderCurrency', () => {
})
})
describe('syncFallbackCurrency', () => {
it('uses fresh balance currency when account currency is empty', () => {
expect(
syncFallbackCurrency(
{ currency: '', providerBaseCurrency: 'EUR', balanceCurrency: 'EUR' },
{ balanceCurrency: 'RUB' },
),
).toBe('RUB')
})
it('prefers explicit account currency over balance', () => {
expect(
syncFallbackCurrency(
{ currency: 'RUB', providerBaseCurrency: 'EUR', balanceCurrency: 'EUR' },
{ balanceCurrency: 'EUR' },
),
).toBe('RUB')
})
})
describe('effectiveAccountBalanceCurrency', () => {
it('uses account currency for balance display', () => {
expect(
+8 -1
View File
@@ -67,14 +67,21 @@ function isIpv4(value: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value)
}
function isIpv6(value: string): boolean {
return value.includes(':')
}
function normalizeIpField(raw: string | string[] | undefined | null): string {
if (raw == null) return ''
if (Array.isArray(raw)) {
let fallback = ''
for (const item of raw) {
const ip = String(item).trim()
if (!ip) continue
if (isIpv4(ip)) return ip
if (!fallback && isIpv6(ip)) fallback = ip
}
return String(raw[0] ?? '').trim()
return fallback
}
return String(raw).trim()
}
+40 -5
View File
@@ -305,6 +305,35 @@ export async function fetchServiceIps(
}
}
export async function fetchVmIps(
baseUrl: string,
credentials: string,
serviceId: string | number,
vmId: string | number,
): Promise<VeespIpItem[]> {
const client = clientFor(baseUrl, credentials)
try {
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}/ips`)
return unwrapList<VeespIpItem>(json, 'ips')
} catch {
return []
}
}
function mergeIpLists(...lists: VeespIpItem[][]): VeespIpItem[] {
const seen = new Set<string>()
const out: VeespIpItem[] = []
for (const list of lists) {
for (const item of list) {
const ip = String(item.ip ?? item.address ?? item.ipaddress ?? '').trim()
if (!ip || seen.has(ip)) continue
seen.add(ip)
out.push(item)
}
}
return out
}
export async function fetchServiceInfo(
baseUrl: string,
credentials: string,
@@ -478,19 +507,25 @@ export async function fetchVpsRecords(
const vmDetails = await Promise.all(
vms.map(async (vm) => {
const vmId = vm.id ?? vm.vmid
if (vmId == null) return vm as VeespVmDetail
const detail = await fetchVmDetail(baseUrl, credentials, serviceId, vmId)
return { ...vm, ...detail } as VeespVmDetail
if (vmId == null) return { vm: vm as VeespVmDetail, vmIps: [] as VeespIpItem[] }
const [detail, vmIps] = await Promise.all([
fetchVmDetail(baseUrl, credentials, serviceId, vmId),
fetchVmIps(baseUrl, credentials, serviceId, vmId),
])
return {
vm: { ...vm, ...detail } as VeespVmDetail,
vmIps,
}
}),
)
return vmDetails.map((vm) => ({
return vmDetails.map(({ vm, vmIps }) => ({
serviceId,
vmId: String(vm.id ?? vm.vmid ?? vmExternalId(serviceId, vm)),
service,
serviceDetail,
vm,
ips,
ips: mergeIpLists(ips, vmIps),
info,
}))
}),
+79
View File
@@ -157,4 +157,83 @@ describe('syncFromVeesp', () => {
.get('vps-veesp-acc-veesp-100-200') as { currency: string }
expect(vps.currency).toBe('USD')
})
it('updates specs and currency on existing VPS during re-sync', async () => {
getSqlite().exec(`
INSERT INTO vps (
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId,
country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization,
bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId,
monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate,
monthlyRate, createdAt, paidUntil, notes, userOverrides
) VALUES (
'vps-veesp-acc-veesp-100-200', '', '', '[]', 'rkn0', 'prov-veesp', 'acc-veesp',
'', '', 'Proxmox', '', 0, 0, 0, 'NVMe', 'KVM',
0, 22, 'root', '', '', '', NULL,
0, 0, 'active', 'monthly', 'EUR', NULL,
500, '2026-01-01', '2027-01-01', 'rkn0 [veesp-100]', '[]'
)
`)
vi.mocked(fetchVpsRecords).mockResolvedValue([
{
serviceId: '100',
vmId: '200',
service: {
id: '100',
domain: 'rkn0',
total: '500.00',
status: 'Active',
billingcycle: 'Monthly',
next_due: '2027-01-01',
category: 'Proxmox',
category_url: 'virtual-private-servers',
name: 'VPS',
},
serviceDetail: {
id: '100',
total: '500.00',
billingcycle: 'Monthly',
next_due: '2027-01-01',
status: 'Active',
domain: 'rkn0',
date_created: '2026-01-01',
},
vm: {
id: '200',
label: 'rkn0',
hostname: 'rkn0',
cpus: '2',
memory: 2048,
disk: 40,
ip: ['198.51.100.5'],
template_label: 'Debian 12',
status: 'active',
},
ips: [{ ip: '198.51.100.5', main: true }],
info: null,
},
])
await syncFromVeesp({
...makeAccount(),
currency: 'RUB',
providerBaseCurrency: 'EUR',
})
const vps = getSqlite()
.prepare('SELECT ip, vcpu, ramGb, diskGb, currency FROM vps WHERE id = ?')
.get('vps-veesp-acc-veesp-100-200') as {
ip: string
vcpu: number
ramGb: number
diskGb: number
currency: string
}
expect(vps.ip).toBe('198.51.100.5')
expect(vps.vcpu).toBe(2)
expect(vps.ramGb).toBe(2)
expect(vps.diskGb).toBe(40)
expect(vps.currency).toBe('RUB')
})
})
+27 -4
View File
@@ -52,6 +52,8 @@ const SYNC_UPDATE_FIELDS = [
'paidUntil',
] as const
const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] as const
function normVal(v: unknown): string {
if (v == null || v === '') return ''
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
@@ -72,17 +74,20 @@ export async function syncFromVeesp(
const fetchVpsData = !skipVpsPayments
const fetchTariffs = !skipTariffs
const fallbackCurrency = syncFallbackCurrency(account)
const [records, balanceInfo, tariffItems, invoices] = await Promise.all([
fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [],
fetchVpsData
? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null)
? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null)
: null,
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => []) : [],
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => []) : [],
fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [],
])
const fallbackCurrency = syncFallbackCurrency(account, {
balanceCurrency: balanceInfo?.currency,
})
let vpsCount = 0
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
@@ -105,6 +110,7 @@ export async function syncFromVeesp(
or(
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
like(schema.vps.notes, `%veesp-${vps.externalId}%`),
like(schema.vps.notes, `%veesp-${record.serviceId}%`),
),
),
)
@@ -126,6 +132,9 @@ export async function syncFromVeesp(
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
@@ -139,7 +148,18 @@ export async function syncFromVeesp(
merged[f] = existing[f as keyof typeof existing] as never
}
}
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
for (const f of SYNC_SPEC_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
const compareFields = [
'ip',
'ipv6',
'dns',
...SYNC_SPEC_FIELDS,
...SYNC_UPDATE_FIELDS,
] as const
const changedFields = compareFields.filter(
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
)
@@ -157,6 +177,9 @@ export async function syncFromVeesp(
city: merged.city,
datacenter: merged.datacenter,
os: merged.os,
vcpu: merged.vcpu,
ramGb: merged.ramGb,
diskGb: merged.diskGb,
status: merged.status,
tariffType: merged.tariffType,
currency: merged.currency,
@@ -145,7 +145,7 @@ export const providerAccountsRepository = {
providerId: input.providerId ?? existing.providerId,
name: input.name ?? existing.name,
panelUrl: input.panelUrl ?? existing.panelUrl,
currency: input.currency ?? existing.currency,
currency: input.currency !== undefined ? input.currency : existing.currency,
billingMode: input.billingMode ?? existing.billingMode,
notes: input.notes ?? existing.notes,
apiType: '',
+9 -5
View File
@@ -32,10 +32,14 @@ export function effectiveAccountBalanceCurrency(
}
export function syncFallbackCurrency(
account: { currency?: string | null; providerBaseCurrency?: string | null },
account: { currency?: string | null; providerBaseCurrency?: string | null; balanceCurrency?: string | null },
options?: { balanceCurrency?: string | null },
): string {
return resolveProviderCurrency(
{ baseCurrency: account.providerBaseCurrency },
account.currency,
)
const accountCur = (account.currency ?? '').trim()
if (accountCur) return accountCur
const freshBalanceCur = (options?.balanceCurrency ?? '').trim()
if (freshBalanceCur) return freshBalanceCur
const storedBalanceCur = (account.balanceCurrency ?? '').trim()
if (storedBalanceCur) return storedBalanceCur
return resolveProviderCurrency({ baseCurrency: account.providerBaseCurrency }, null)
}