openvibers/openvibe.host Commits

Host: OVHcloud dedicated-server plans, with a dedicated section on the comparison page (#287)

OP OpenVibers committed 7 days ago · 37722f3eb0 · 7 files, +105 −9

Maps Sources' ovhcloud-baremetal items to dedicated plans: sockets times cores, one disk's size times the disk count, installation fee kept. The catalog comparison page shows the cheapest dedicated servers in their own section, using the existing plan table; kind=dedicated remains the full dedicated view.

Changes

ddiff --git a/README.md b/README.md
iindex 87d13dd..699a69d 100644
---- a/README.md
++++ b/README.md
@@@ -18,10 +18,13 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- **Where plans come from.**
- OpenVibe.Sources items in category `hosting` (official provider APIs), read with `openvibe-publishing/ingest`: an event consumer at `/internal/catalog/events` plus a cursor pull. The switch is `HOST_CATALOG_INGEST` (default off) with `HOST_CATALOG_SOURCES_URL`.
-- - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types`, `aws-lightsail-bundles` and `vultr-plans`.
-- Lightsail's price list gives numbers as text and prices by the hour; a bundle's monthly price is its hourly rate for
-- 744 hours (AWS bills up to that cap), and IPv6-only bundles show 0 IPv4 addresses. Vultr's API host disallows
-- robots, so that Sources seed stays disabled.
++ - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types`, `aws-lightsail-bundles` and `vultr-plans`,
++ plus `ovhcloud-vps` and `ovhcloud-baremetal` and Azure. Lightsail's price list gives numbers as text and prices by
++ the hour; a bundle's monthly price is its hourly rate for 744 hours (AWS bills up to that cap), and IPv6-only
++ bundles show 0 IPv4 addresses. OVHcloud's dedicated catalog keeps a plan's default memory, storage and bandwidth
++ addons apart from the plan, so the Sources mapping joins each default addon to its own product; Host multiplies the
++ socket count by the cores and one disk's size by the disk count. Vultr's API host disallows robots, so that
++ Sources seed stays disabled.
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
ddiff --git a/server/catalog/mapping.js b/server/catalog/mapping.js
iindex 90c4c0e..51ff70b 100644
---- a/server/catalog/mapping.js
++++ b/server/catalog/mapping.js
@@@ -7,6 +7,7 @@ const SOURCES = Object.freeze({
'linode-types': { slug: 'linode', name: 'Linode', homepage_url: 'https://www.linode.com/', source_url: 'https://www.linode.com/pricing/' },
'aws-lightsail-bundles': { slug: 'aws-lightsail', name: 'Amazon Lightsail', homepage_url: 'https://aws.amazon.com/lightsail/', source_url: 'https://aws.amazon.com/lightsail/pricing/' },
'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },
++ 'ovhcloud-baremetal': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/bare-metal/prices/' },
'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },
});
const number = (v, field) => {
@@@ -70,6 +71,44 @@ function ovhcloud(x, id, title) {
price_monthly: monthly, price_hourly: null, ipv4: null, locations: null,
};
}
++// OVHcloud's dedicated-server catalogs (Sources `ovhcloud-baremetal`): the plan's CPU is on its product, while its
++// default memory, storage and bandwidth are addons of that plan, each with a product of its own. The source joins
++// them and gives the size of one disk and the number of disks; the plan's total is their product. A socket count is
++// usually stated (two in a dual-socket HGR); where the catalog omits it the CPU is one socket, so the cores stay as
++// the catalog gives them. The installation fee is the catalog's phase 0 none price, which OVHcloud states beside the
++// monthly price on its own page ($204 and $204 for Advance-1 2026). Prices are hundred-millionths of a dollar.
++function ovhBaremetal(x, id, title) {
++ if (x.currency !== 'USD') throw new TypeError('OVHcloud price is not in USD');
++ const units = number(x.price_monthly, 'monthly price');
++ if (units == null) throw new TypeError('missing monthly price');
++ const monthly = Math.round(units / 1e6) / 100;
++ if (monthly <= 0) return null;
++ const setupUnits = number(x.setup_fee, 'setup fee');
++ const setup = setupUnits == null ? null : Math.round(setupUnits / 1e6) / 100;
++ const cores = number(x.cpu_cores, 'cpu cores');
++ const sockets = number(x.cpu_sockets, 'cpu sockets');
++ const memoryGb = number(x.memory_gb, 'memory');
++ const perDisk = number(x.disk_capacity_gb, 'disk capacity');
++ const diskCount = number(x.disk_count, 'disk count');
++ const diskGb = perDisk == null || diskCount == null ? null : perDisk * diskCount;
++ const iface = String(x.disk_interface || '').toLowerCase();
++ const technology = String(x.disk_technology || '').toLowerCase();
++ const diskType = diskGb > 0
++ ? (/nvme/.test(iface) ? 'nvme' : /ssd/.test(iface) || /ssd/.test(technology) ? 'ssd' : /hdd|sas|sata/.test(iface) || /hdd|sas|sata/.test(technology) ? 'hdd' : null)
++ : null;
++ const limit = number(x.bandwidth_limit, 'bandwidth limit');
++ return {
++ name: (typeof title === 'string' && title.trim()) || id, kind: 'dedicated',
++ vcpu: cores == null ? null : cores * (sockets == null ? 1 : sockets),
++ ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024),
++ disk_gb: diskGb, disk_type: diskType,
++ bandwidth_gb: null, unmetered: limit == null ? null : limit === 0, port_mbps: number(x.bandwidth_mbps, 'bandwidth'),
++ price_monthly: monthly, price_hourly: null, ipv4: null,
++ locations: Array.isArray(x.locations) && x.locations.length ? x.locations : null,
++ setup_fee: setup,
++ };
++}
++
// Vultr's plan types as its pricing page names them; a plan is named from its type, CPU vendor, vCPUs and memory
// ("High Performance AMD · 1 vCPU, 1 GB"), never by its API id alone.
const VULTR_TYPES = Object.freeze({ vc2: 'Cloud Compute', vhf: 'High Frequency', vhp: 'High Performance', voc: 'Optimized Cloud', vcg: 'Cloud GPU', vbm: 'Bare Metal', vdc: 'Dedicated Cloud' });
@@@ -118,7 +157,7 @@ function mapItem(item) {
// Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates
// become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No
// source states a renewal price for a server plan, so it stays unknown rather than guessed.
-- price_term: 'monthly', renewal_price: null,
++ price_term: 'monthly', renewal_price: null, setup_fee: null,
};
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'azure-retail-prices') {
@@@ -135,6 +174,10 @@ function mapItem(item) {
const plan = ovhcloud(x, id, item.title);
return plan && { ...common, ...plan };
}
++ if (item.source_key === 'ovhcloud-baremetal') {
++ const plan = ovhBaremetal(x, id, item.title);
++ return plan && { ...common, ...plan };
++ }
const vultr = item.source_key === 'vultr-plans';
const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');
if (monthly == null) throw new TypeError('missing monthly price');
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 0360c1b..647e05f 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -138,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
}
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
-- const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
++ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
if (old) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
ddiff --git a/server/http/catalog-views.js b/server/http/catalog-views.js
iindex a38ebcb..2148883 100644
---- a/server/http/catalog-views.js
++++ b/server/http/catalog-views.js
@@@ -84,10 +84,16 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
}
async function index(query = {}) {
-- const [result, providers, currencies] = await Promise.all([catalog.plans(query), catalog.providers(), catalog.currencies()]);
++ // The comparison table is whatever the filters ask for; dedicated plans also get their own section, unless
++ // the reader already asked for that kind (then the table itself is the dedicated view).
++ const dedicatedQuery = query.kind ? null : { kind: 'dedicated', currency: query.currency || 'USD' };
++ const [result, providers, currencies, dedicated] = await Promise.all([
++ catalog.plans(query), catalog.providers(), catalog.currencies(),
++ dedicatedQuery ? catalog.plans(dedicatedQuery).then((r) => r.plans) : Promise.resolve([]),
++ ]);
return {
title: 'Compare hosting plans',
-- body: render.catalog({ result, query, providers, currencies }),
++ body: render.catalog({ result, query, providers, currencies, dedicated }),
indexable: !Object.keys(query).length,
description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
jsonLd: render.jsonLd.catalog(baseUrl, result.plans),
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex 4fae5da..dbfeeb6 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -223,13 +223,21 @@ function method(hasAffiliate) {
</section>`;
}
--function catalog({ result, query, providers, currencies }) {
++function catalog({ result, query, providers, currencies, dedicated = [] }) {
const plans = result.plans;
const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
const empty = plans.length ? '' : filtered
? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`
: `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;
const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog?${new URLSearchParams({ ...query, cursor: result.next_cursor })}`, 'Next 50 plans →', 'button hc-button-quiet')}</p>` : '';
++ // The catalog filter already has a Dedicated kind; when there are dedicated plans, the comparison page also
++ // shows them as their own section, with the same table. A kind=dedicated view is that table, so no second copy.
++ const dedicatedSection = dedicated.length ? `<section aria-labelledby="hc-dedicated-h">
++<h2 id="hc-dedicated-h">Dedicated servers compared</h2>
++<p class="hc-dim">Bare-metal plans from providers' official catalogs, with the same columns and the day each price was checked.</p>
++${planTable(dedicated.slice(0, 10), { caption: `Dedicated servers, lowest price first${dedicated.length > 10 ? ' (10 shown)' : ''}` })}
++${dedicated.length > 10 ? `<p class="hc-more">${link('/catalog?kind=dedicated', `All ${dedicated.length} dedicated servers →`, 'button hc-button-quiet')}</p>` : ''}
++</section>` : '';
return `<header class="hc-head">
<p class="hc-eyebrow">OpenVibe.Host · hosting compared · alpha</p>
<h1>Compare hosting plans</h1>
@@@ -238,6 +246,7 @@ function catalog({ result, query, providers, currencies }) {
${filters(query, providers, currencies)}
${planTable(plans, { caption: `${plans.length}${result.next_cursor ? '+' : ''} plan${plans.length === 1 ? '' : 's'}, sorted by ${(SORTS.find(([v]) => v === (query.sort || 'price')) || SORTS[0])[1].toLowerCase()}` })}
${empty}${next}
++${dedicatedSection}
<p class="hc-links">${link('/catalog/promos', 'Current promotions')} · ${link('/catalog/providers', 'All providers')}</p>
${method(plans.some((p) => p.affiliate_url))}`;
}
ddiff --git a/test/catalog-ingest.test.js b/test/catalog-ingest.test.js
iindex 675662f..8288901 100644
---- a/test/catalog-ingest.test.js
++++ b/test/catalog-ingest.test.js
@@@ -65,6 +65,32 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
const row = (await catalog.plans({ provider: 'ovhcloud' })).plans[0];
assert.deepStrictEqual([row.name, Number(row.price_monthly), row.unmetered, Number(row.port_mbps), row.bandwidth_gb], ['VPS-1 2027', 5.35, true, 500, null]);
});
++ await check('an OVHcloud dedicated plan maps from the source join: sockets multiply cores, one disk size times the count, and the installation fee is kept', async () => {
++ const base = {
++ product: '24adv01', currency: 'USD', range: 'advance',
++ cpu_cores: 6, cpu_sockets: 1, cpu_model: 'EPYC 4244P', cpu_brand: 'AMD', memory_gb: 32,
++ disk_capacity_gb: 960, disk_count: 2, disk_interface: 'NVMe', disk_technology: 'SSD',
++ bandwidth_mbps: 3000, bandwidth_limit: 0, price_monthly: 14700000000, setup_fee: 14700000000,
++ locations: ['vin', 'hil'],
++ };
++ const p = mapItem(item('ovhcloud-baremetal', '24adv01-v3-us', base, at, 'ADVANCE-1 | AMD EPYC 4244P'));
++ assert.deepStrictEqual([p.provider.slug, p.name, p.kind, p.vcpu, p.ram_mb, p.disk_gb, p.disk_type, p.bandwidth_gb, p.unmetered, p.port_mbps, p.price_monthly, p.price_hourly, p.setup_fee, p.locations, p.source_url],
++ ['ovhcloud', 'ADVANCE-1 | AMD EPYC 4244P', 'dedicated', 6, 32768, 1920, 'nvme', null, true, 3000, 147, null, 147, ['vin', 'hil'], 'https://us.ovhcloud.com/bare-metal/prices/']);
++ const dual = mapItem(item('ovhcloud-baremetal', 'dual', { ...base, cpu_cores: 24, cpu_sockets: 2, memory_gb: 256, disk_capacity_gb: 3840, disk_count: 6, price_monthly: 165500000000, setup_fee: 165500000000 }));
++ assert.deepStrictEqual([dual.vcpu, dual.ram_mb, dual.disk_gb, dual.unmetered, dual.price_monthly, dual.setup_fee], [48, 262144, 23040, true, 1655, 1655]);
++ // A legacy HG product states 28 cores with no socket count: one socket, not an invented second one.
++ const hg = mapItem(item('ovhcloud-baremetal', 'hg', { ...base, cpu_cores: 28, cpu_sockets: null, setup_fee: 0 }));
++ assert.deepStrictEqual([hg.vcpu, hg.setup_fee], [28, 0]);
++ // A Scale plan sold with no drives states 0 and 0; the comparison shows 0 GB, not Unknown.
++ const bare = mapItem(item('ovhcloud-baremetal', 'bare', { ...base, disk_capacity_gb: 0, disk_count: 0 }));
++ assert.deepStrictEqual([bare.disk_gb, bare.disk_type], [0, null]);
++ assert.throws(() => mapItem(item('ovhcloud-baremetal', 'x', { ...base, currency: 'EUR' })), /not in USD/);
++ assert.throws(() => mapItem(item('ovhcloud-baremetal', 'x', { ...base, price_monthly: null })), /missing monthly price/);
++ assert.strictEqual(mapItem(item('ovhcloud-baremetal', 'x', { ...base, price_monthly: 0 })), null, 'a zero price is not listed');
++ await catalog.applyItem(item('ovhcloud-baremetal', '24adv01-v3-us', base, at, 'ADVANCE-1 | AMD EPYC 4244P'));
++ const row = (await catalog.plans({ provider: 'ovhcloud', kind: 'dedicated' })).plans[0];
++ assert.deepStrictEqual([row.name, row.kind, Number(row.price_monthly), Number(row.setup_fee), row.source_key, row.locations], ['ADVANCE-1 | AMD EPYC 4244P', 'dedicated', 147, 147, 'ovhcloud-baremetal', ['vin', 'hil']]);
++ });
await check('upsert preserves first seen and records only a changed price', async () => {
const raw = { vcpu_count: 1, ram: 1024, disk: 25, bandwidth: 1000, monthly_cost: 5, hourly_cost: 0.007, type: 'vc2' };
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', raw));
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex bf91c75..7f7959f 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -96,6 +96,15 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
});
++ await check('the comparison page shows dedicated plans in their own section, and kind=dedicated is that table once', async () => {
++ const metal = await plan(p, 'metal', 120, { kind: 'dedicated', vcpu: 8, ram_mb: 32768, disk_gb: 960, disk_type: 'nvme', setup_fee: 120 });
++ const page = await t.api('GET', '/catalog');
++ assert.match(page.text, /Dedicated servers compared/);
++ assert.ok(page.text.includes(`href="/catalog/plans/${metal.id}"`), 'the dedicated plan is linked');
++ const only = await t.api('GET', '/catalog?kind=dedicated');
++ assert.ok(only.text.includes(`href="/catalog/plans/${metal.id}"`), 'the dedicated view lists it');
++ assert.doesNotMatch(only.text, /Dedicated servers compared/, 'the dedicated view is the table itself, not a second copy');
++ });
await check('the sitemap dates catalog pages by their newest price check', async () => {
const xml = (await t.api('GET', '/sitemap.xml')).text;
const planUrl = xml.match(/<url><loc>[^<]*\/catalog\/plans\/[^<]+<\/loc><lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod><\/url>/);