diff --git a/packages/cloud/hetzner/src/index.ts b/packages/cloud/hetzner/src/index.ts index bd461fb2..9e07550c 100644 --- a/packages/cloud/hetzner/src/index.ts +++ b/packages/cloud/hetzner/src/index.ts @@ -131,8 +131,8 @@ export default defineCloud({ } const priceForLocation = match.prices.find(p => p.location === location) ?? match.prices[0]; - const hourly = parseFloat(priceForLocation?.price_hourly?.net ?? '0'); - const monthly = parseFloat(priceForLocation?.price_monthly?.net ?? '0'); + const hourly = parseHetznerPrice(priceForLocation?.price_hourly?.net); + const monthly = parseHetznerPrice(priceForLocation?.price_monthly?.net); return { hourly, @@ -167,7 +167,7 @@ export default defineCloud({ kind: spec.kind, status: 'provisioning', createdAt: vol.volume.created, - hourlyRate: parseFloat(vol.volume.price_per_month) / 730, + hourlyRate: parseHetznerPrice(vol.volume.price_per_month) / 730, currency: 'EUR', region: vol.volume.location.name, } satisfies Instance; @@ -319,7 +319,7 @@ function volumeToInstance(v: HcloudVolume): Instance { kind: 'block-storage', status: statusMap[v.status] ?? 'provisioning', createdAt: v.created, - hourlyRate: parseFloat(v.price_per_month) / 730, + hourlyRate: parseHetznerPrice(v.price_per_month) / 730, currency: 'EUR', region: v.location.name, }; @@ -338,7 +338,7 @@ function pickServerType(serverTypes: HcloudServerType[], spec: InstanceSpec, loc let candidates = serverTypes.filter(st => !st.deprecated && st.prices.some(p => p.location === location) && - parseFloat(st.prices.find(p => p.location === location)?.price_monthly?.net ?? '0') > 0 + parseHetznerPrice(st.prices.find(p => p.location === location)?.price_monthly?.net) > 0 ); // Kind-based filtering @@ -363,20 +363,27 @@ function pickServerType(serverTypes: HcloudServerType[], spec: InstanceSpec, loc if (spec.maxHourlyPrice) { candidates = candidates.filter(st => { const priceForLocation = st.prices.find(p => p.location === location); - return priceForLocation ? parseFloat(priceForLocation.price_hourly.net) <= spec.maxHourlyPrice! : false; + return priceForLocation ? parseHetznerPrice(priceForLocation.price_hourly.net) <= spec.maxHourlyPrice! : false; }); } // Cheapest first (by monthly net price for target location) candidates.sort((a, b) => { - const aPrice = parseFloat(a.prices.find(p => p.location === location)?.price_monthly?.net ?? '0'); - const bPrice = parseFloat(b.prices.find(p => p.location === location)?.price_monthly?.net ?? '0'); + const aPrice = parseHetznerPrice(a.prices.find(p => p.location === location)?.price_monthly?.net); + const bPrice = parseHetznerPrice(b.prices.find(p => p.location === location)?.price_monthly?.net); return aPrice - bPrice; }); return candidates[0] ?? null; } +export function parseHetznerPrice(value: string | undefined): number { + const text = value?.trim(); + if (!text || !/^\d+(?:\.\d+)?$/.test(text)) return 0; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : 0; +} + let serverTypesCache: HcloudServerType[] | null = null; async function fetchServerTypes(ctx: { secret(k: string): string | undefined; log(msg: string, level?: 'info' | 'warn' | 'error'): void }): Promise { diff --git a/packages/cloud/hetzner/src/price.test.ts b/packages/cloud/hetzner/src/price.test.ts new file mode 100644 index 00000000..90453d88 --- /dev/null +++ b/packages/cloud/hetzner/src/price.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { parseHetznerPrice } from './index.js'; + +describe('parseHetznerPrice', () => { + it('accepts decimal API prices', () => { + expect(parseHetznerPrice('0.005')).toBe(0.005); + expect(parseHetznerPrice(' 12.50 ')).toBe(12.5); + }); + + it('rejects malformed and non-decimal API prices instead of partially parsing them', () => { + for (const value of ['12.50 EUR', '1e-3', 'Infinity', '-1', '']) { + expect(parseHetznerPrice(value)).toBe(0); + } + }); +});