fix(veesp): обновление IP, характеристик и валюты при повторном синке
Docker / build (push) Failing after 21s
Docker / build (push) Failing after 21s
Перезапись vcpu/ram/disk/ip на update, fetch VM ips, приоритет валюты из balance API. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { resolveProviderCurrency } from '@cfdm/shared/utils/currency'
|
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', () => {
|
describe('resolveProviderCurrency', () => {
|
||||||
it('prefers account currency over provider baseCurrency', () => {
|
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', () => {
|
describe('effectiveAccountBalanceCurrency', () => {
|
||||||
it('uses account currency for balance display', () => {
|
it('uses account currency for balance display', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -67,14 +67,21 @@ function isIpv4(value: string): boolean {
|
|||||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value)
|
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 {
|
function normalizeIpField(raw: string | string[] | undefined | null): string {
|
||||||
if (raw == null) return ''
|
if (raw == null) return ''
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
|
let fallback = ''
|
||||||
for (const item of raw) {
|
for (const item of raw) {
|
||||||
const ip = String(item).trim()
|
const ip = String(item).trim()
|
||||||
|
if (!ip) continue
|
||||||
if (isIpv4(ip)) return ip
|
if (isIpv4(ip)) return ip
|
||||||
|
if (!fallback && isIpv6(ip)) fallback = ip
|
||||||
}
|
}
|
||||||
return String(raw[0] ?? '').trim()
|
return fallback
|
||||||
}
|
}
|
||||||
return String(raw).trim()
|
return String(raw).trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
export async function fetchServiceInfo(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
credentials: string,
|
credentials: string,
|
||||||
@@ -478,19 +507,25 @@ export async function fetchVpsRecords(
|
|||||||
const vmDetails = await Promise.all(
|
const vmDetails = await Promise.all(
|
||||||
vms.map(async (vm) => {
|
vms.map(async (vm) => {
|
||||||
const vmId = vm.id ?? vm.vmid
|
const vmId = vm.id ?? vm.vmid
|
||||||
if (vmId == null) return vm as VeespVmDetail
|
if (vmId == null) return { vm: vm as VeespVmDetail, vmIps: [] as VeespIpItem[] }
|
||||||
const detail = await fetchVmDetail(baseUrl, credentials, serviceId, vmId)
|
const [detail, vmIps] = await Promise.all([
|
||||||
return { ...vm, ...detail } as VeespVmDetail
|
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,
|
serviceId,
|
||||||
vmId: String(vm.id ?? vm.vmid ?? vmExternalId(serviceId, vm)),
|
vmId: String(vm.id ?? vm.vmid ?? vmExternalId(serviceId, vm)),
|
||||||
service,
|
service,
|
||||||
serviceDetail,
|
serviceDetail,
|
||||||
vm,
|
vm,
|
||||||
ips,
|
ips: mergeIpLists(ips, vmIps),
|
||||||
info,
|
info,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -157,4 +157,83 @@ describe('syncFromVeesp', () => {
|
|||||||
.get('vps-veesp-acc-veesp-100-200') as { currency: string }
|
.get('vps-veesp-acc-veesp-100-200') as { currency: string }
|
||||||
expect(vps.currency).toBe('USD')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ const SYNC_UPDATE_FIELDS = [
|
|||||||
'paidUntil',
|
'paidUntil',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] as const
|
||||||
|
|
||||||
function normVal(v: unknown): string {
|
function normVal(v: unknown): string {
|
||||||
if (v == null || v === '') return ''
|
if (v == null || v === '') return ''
|
||||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||||
@@ -72,17 +74,20 @@ export async function syncFromVeesp(
|
|||||||
|
|
||||||
const fetchVpsData = !skipVpsPayments
|
const fetchVpsData = !skipVpsPayments
|
||||||
const fetchTariffs = !skipTariffs
|
const fetchTariffs = !skipTariffs
|
||||||
const fallbackCurrency = syncFallbackCurrency(account)
|
|
||||||
|
|
||||||
const [records, balanceInfo, tariffItems, invoices] = await Promise.all([
|
const [records, balanceInfo, tariffItems, invoices] = await Promise.all([
|
||||||
fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [],
|
fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [],
|
||||||
fetchVpsData
|
fetchVpsData
|
||||||
? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null)
|
? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null)
|
||||||
: null,
|
: null,
|
||||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => []) : [],
|
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => []) : [],
|
||||||
fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [],
|
fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const fallbackCurrency = syncFallbackCurrency(account, {
|
||||||
|
balanceCurrency: balanceInfo?.currency,
|
||||||
|
})
|
||||||
|
|
||||||
let vpsCount = 0
|
let vpsCount = 0
|
||||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||||
|
|
||||||
@@ -105,6 +110,7 @@ export async function syncFromVeesp(
|
|||||||
or(
|
or(
|
||||||
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
|
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
|
||||||
like(schema.vps.notes, `%veesp-${vps.externalId}%`),
|
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,
|
city: vps.city,
|
||||||
datacenter: vps.datacenter,
|
datacenter: vps.datacenter,
|
||||||
os: vps.os,
|
os: vps.os,
|
||||||
|
vcpu: vps.vcpu,
|
||||||
|
ramGb: vps.ramGb,
|
||||||
|
diskGb: vps.diskGb,
|
||||||
status: vps.status,
|
status: vps.status,
|
||||||
tariffType: vps.tariffType,
|
tariffType: vps.tariffType,
|
||||||
currency: vps.currency,
|
currency: vps.currency,
|
||||||
@@ -139,7 +148,18 @@ export async function syncFromVeesp(
|
|||||||
merged[f] = existing[f as keyof typeof existing] as never
|
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(
|
const changedFields = compareFields.filter(
|
||||||
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
(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,
|
city: merged.city,
|
||||||
datacenter: merged.datacenter,
|
datacenter: merged.datacenter,
|
||||||
os: merged.os,
|
os: merged.os,
|
||||||
|
vcpu: merged.vcpu,
|
||||||
|
ramGb: merged.ramGb,
|
||||||
|
diskGb: merged.diskGb,
|
||||||
status: merged.status,
|
status: merged.status,
|
||||||
tariffType: merged.tariffType,
|
tariffType: merged.tariffType,
|
||||||
currency: merged.currency,
|
currency: merged.currency,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const providerAccountsRepository = {
|
|||||||
providerId: input.providerId ?? existing.providerId,
|
providerId: input.providerId ?? existing.providerId,
|
||||||
name: input.name ?? existing.name,
|
name: input.name ?? existing.name,
|
||||||
panelUrl: input.panelUrl ?? existing.panelUrl,
|
panelUrl: input.panelUrl ?? existing.panelUrl,
|
||||||
currency: input.currency ?? existing.currency,
|
currency: input.currency !== undefined ? input.currency : existing.currency,
|
||||||
billingMode: input.billingMode ?? existing.billingMode,
|
billingMode: input.billingMode ?? existing.billingMode,
|
||||||
notes: input.notes ?? existing.notes,
|
notes: input.notes ?? existing.notes,
|
||||||
apiType: '',
|
apiType: '',
|
||||||
|
|||||||
@@ -32,10 +32,14 @@ export function effectiveAccountBalanceCurrency(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function syncFallbackCurrency(
|
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 {
|
): string {
|
||||||
return resolveProviderCurrency(
|
const accountCur = (account.currency ?? '').trim()
|
||||||
{ baseCurrency: account.providerBaseCurrency },
|
if (accountCur) return accountCur
|
||||||
account.currency,
|
const freshBalanceCur = (options?.balanceCurrency ?? '').trim()
|
||||||
)
|
if (freshBalanceCur) return freshBalanceCur
|
||||||
|
const storedBalanceCur = (account.balanceCurrency ?? '').trim()
|
||||||
|
if (storedBalanceCur) return storedBalanceCur
|
||||||
|
return resolveProviderCurrency({ baseCurrency: account.providerBaseCurrency }, null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user