openvibers/openvibe.host Commits
Map Microsoft Azure retail prices into the catalog (#211)
Changes
@@@ -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/' },++ '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) => {if (v == null) return null;@@@ -81,6 +82,29 @@ function vultrName(x, id) {return `${type}${vendor} · ${vcpu} vCPU, ${memory}`;}++// Microsoft Azure's retail price list (Sources `azure-retail-prices`): one row per meter, region and SKU. A virtual++// machine is priced by the hour (0.922 an hour is the D96s v5 in eastus) and Azure bills by the hour, so a month is++// 730 hours; a storage row is priced per GB per month and says so in its name. The row's effective date is the++// observation time, so a price change is dated by the day it took effect (Host #210).++function azure(x, id, title) {++ if (x.currency != null && x.currency !== 'USD') throw new TypeError('Azure price is not in USD');++ const price = number(x.price, 'price');++ if (price == null) throw new TypeError('missing price');++ const unit = String(x.unit || '');++ const region = typeof x.region === 'string' && x.region ? x.region : null;++ const meter = typeof x.meter_name === 'string' ? x.meter_name : '';++ const plain = { vcpu: null, ram_mb: null, disk_gb: null, disk_type: null, bandwidth_gb: null, ipv4: null, unmetered: null, port_mbps: null, locations: region ? [region] : null };++ if (/hour/i.test(unit)) {++ const monthly = Math.round(price * 730 * 100) / 100;++ if (monthly <= 0) return null;++ const variant = /spot/i.test(meter) ? ' (Spot)' : /low priority/i.test(meter) ? ' (Low Priority)' : '';++ return { ...plain, name: `${(typeof title === 'string' && title.trim()) || id}${variant}`, kind: 'cloud', price_monthly: monthly, price_hourly: price };++ }++ if (/gb\/month/i.test(unit)) {++ return { ...plain, name: `${(typeof title === 'string' && title.trim()) || id} (per GB/month)`, kind: 'storage', price_monthly: price, price_hourly: null };++ }++ throw new TypeError('unknown Azure price unit');++}function mapItem(item) {const provider = SOURCES[item && item.source_key];if (!provider) return null;@@@ -93,6 +117,16 @@ function mapItem(item) {source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),};if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };++ if (item.source_key === 'azure-retail-prices') {++ const plan = azure(x, id, item.title);++ if (!plan) return null;++ // The row's own effective date is when the price took effect: it is the observation time the price trend++ // (catalog_price_changes, Host #210) is dated by.++ const effective = Date.parse(x.effective_start || '');++ const observed = Number.isFinite(effective) ? effective : common.checked_at;++ const region = typeof x.region === 'string' && x.region ? x.region : null;++ return { ...common, ...plan, external_id: region ? `${id}@${region}` : id, checked_at: observed };++ }if (item.source_key === 'ovhcloud-vps') {const plan = ovhcloud(x, id, item.title);return plan && { ...common, ...plan };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * Microsoft Azure's retail price list in the catalog (server/catalog/mapping.js): a virtual machine carries its size,++ * its region, its price an hour and a month, and the date the price took effect is the observation time the price++ * trend (catalog_price_changes, Host #210) is dated by.++ */++const assert = require('assert');++const { testDb } = require('./helpers/db');++const { createStore } = require('../server/db');++const { createCatalog } = require('../server/catalog/store');++const { mapItem } = require('../server/catalog/mapping');++const { check, done } = require('./helpers/boot');++const at = '2026-10-30T12:00:00Z';++++// The shape the Sources `api` adapter stores for an Azure price row (a recorded fixture: no live call here).++const item = (id, fields, time = at, title = id) => ({++ id: `itm_${'0'.repeat(26)}`, source_key: 'azure-retail-prices', category: 'hosting', kind: 'plan', identity: id,++ canonical_url: null, title, summary: null, authors: [], published_at: null, source_updated_at: null, fields, revision: 1,++ provenance: { retrieved_at: time, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null }, removed: null,++});++const vm = (over = {}) => ({ sku_id: 'DZH318Z08M9W/006F', sku_name: 'Standard_D8s_v5', meter_name: 'D8s v5', product_name: 'Virtual Machines Dsv5 Series', service_name: 'Virtual Machines', region: 'eastus', location: 'US East', price: 0.96, unit: '1 Hour', currency: 'USD', effective_start: '2021-11-01T00:00:00Z', tier_minimum_units: 0, ...over });++++(async () => {++ const db = await testDb();++ const store = createStore(db.db, { now: () => Date.parse(at) });++ const catalog = createCatalog({ store });++ try {++ await check('an Azure virtual machine maps with its size, region, hourly and monthly price', async () => {++ const p = mapItem(item('DZH318Z08M9W/006F', vm(), at, 'Standard_D8s_v5'));++ assert.deepStrictEqual([p.provider.slug, p.provider.name, p.name, p.kind, p.locations, p.price_hourly, p.price_monthly, p.currency, p.source_url],++ ['azure', 'Microsoft Azure', 'Standard_D8s_v5', 'cloud', ['eastus'], 0.96, 700.8, 'USD', 'https://azure.microsoft.com/en-us/pricing/']);++ assert.strictEqual(p.external_id, 'DZH318Z08M9W/006F@eastus', 'the same SKU in another region is another plan');++ assert.strictEqual(p.checked_at, Date.parse('2021-11-01T00:00:00Z'), 'the effective date is the observation time');++ const spot = mapItem(item('DZH318Z08M9W/006G', vm({ meter_name: 'D8s v5 Spot', price: 0.29, sku_id: 'DZH318Z08M9W/006G' }), at, 'Standard_D8s_v5'));++ assert.strictEqual(spot.name, 'Standard_D8s_v5 (Spot)');++ const storage = mapItem(item('DZH318Z0BNZJ/00WH', { ...vm(), sku_id: 'DZH318Z0BNZJ/00WH', sku_name: 'Cold LRS', meter_name: 'Cold LRS Data Stored', product_name: 'Blob Storage', service_name: 'Storage', price: 0.0036, unit: '1 GB/Month' }, at, 'Cold LRS'));++ assert.deepStrictEqual([storage.kind, storage.name, storage.price_monthly, storage.price_hourly], ['storage', 'Cold LRS (per GB/month)', 0.0036, null]);++ assert.throws(() => mapItem(item('X', vm({ unit: '1 Day' }), at, 'x')), /unknown Azure price unit/);++ });++++ await check('the plan row lands in the catalog with the effective date, and a later price is dated by its own effective date', async () => {++ await catalog.applyItem(item('DZH318Z08M9W/006F', vm(), at, 'Standard_D8s_v5'));++ const row = (await catalog.plans({ provider: 'azure' })).plans[0];++ assert.deepStrictEqual([row.provider_slug, row.name, Number(row.price_monthly), Number(row.price_hourly), row.locations, new Date(row.checked_at).toISOString()],++ ['azure', 'Standard_D8s_v5', 700.8, 0.96, ['eastus'], '2021-11-01T00:00:00.000Z']);++ // The price changes and Microsoft says the new price took effect on 2026-06-01: the change is dated then.++ await catalog.applyItem(item('DZH318Z08M9W/006F', vm({ price: 1.02, effective_start: '2026-06-01T00:00:00Z' }), '2026-10-30T12:00:00Z', 'Standard_D8s_v5'));++ const plan = await catalog.plan(row.id);++ assert.strictEqual(Number(plan.price_monthly), 744.6);++ assert.deepStrictEqual(plan.price_history.map((c) => [new Date(c.observed_at).toISOString().slice(0, 10), Number(c.price_monthly_before), Number(c.price_monthly_after)]),++ [['2026-06-01', 700.8, 744.6]]);++ });++ } finally {++ await db.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -7,6 +7,7 @@ const SOURCES = Object.freeze({
@@ -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/' },
'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/' },
'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-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },
+ '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) => {
const number = (v, field) => {
if (v == null) return null;
if (v == null) return null;
@@ -81,6 +82,29 @@ function vultrName(x, id) {
@@ -81,6 +82,29 @@ function vultrName(x, id) {
return `${type}${vendor} · ${vcpu} vCPU, ${memory}`;
return `${type}${vendor} · ${vcpu} vCPU, ${memory}`;
}
}
+// Microsoft Azure's retail price list (Sources `azure-retail-prices`): one row per meter, region and SKU. A virtual
+// machine is priced by the hour (0.922 an hour is the D96s v5 in eastus) and Azure bills by the hour, so a month is
+// 730 hours; a storage row is priced per GB per month and says so in its name. The row's effective date is the
+// observation time, so a price change is dated by the day it took effect (Host #210).
+function azure(x, id, title) {
+ if (x.currency != null && x.currency !== 'USD') throw new TypeError('Azure price is not in USD');
+ const price = number(x.price, 'price');
+ if (price == null) throw new TypeError('missing price');
+ const unit = String(x.unit || '');
+ const region = typeof x.region === 'string' && x.region ? x.region : null;
+ const meter = typeof x.meter_name === 'string' ? x.meter_name : '';
+ const plain = { vcpu: null, ram_mb: null, disk_gb: null, disk_type: null, bandwidth_gb: null, ipv4: null, unmetered: null, port_mbps: null, locations: region ? [region] : null };
+ if (/hour/i.test(unit)) {
+ const monthly = Math.round(price * 730 * 100) / 100;
+ if (monthly <= 0) return null;
+ const variant = /spot/i.test(meter) ? ' (Spot)' : /low priority/i.test(meter) ? ' (Low Priority)' : '';
+ return { ...plain, name: `${(typeof title === 'string' && title.trim()) || id}${variant}`, kind: 'cloud', price_monthly: monthly, price_hourly: price };
+ }
+ if (/gb\/month/i.test(unit)) {
+ return { ...plain, name: `${(typeof title === 'string' && title.trim()) || id} (per GB/month)`, kind: 'storage', price_monthly: price, price_hourly: null };
+ }
+ throw new TypeError('unknown Azure price unit');
+}
function mapItem(item) {
function mapItem(item) {
const provider = SOURCES[item && item.source_key];
const provider = SOURCES[item && item.source_key];
if (!provider) return null;
if (!provider) return null;
@@ -93,6 +117,16 @@ function mapItem(item) {
@@ -93,6 +117,16 @@ function mapItem(item) {
source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
};
};
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
+ if (item.source_key === 'azure-retail-prices') {
+ const plan = azure(x, id, item.title);
+ if (!plan) return null;
+ // The row's own effective date is when the price took effect: it is the observation time the price trend
+ // (catalog_price_changes, Host #210) is dated by.
+ const effective = Date.parse(x.effective_start || '');
+ const observed = Number.isFinite(effective) ? effective : common.checked_at;
+ const region = typeof x.region === 'string' && x.region ? x.region : null;
+ return { ...common, ...plan, external_id: region ? `${id}@${region}` : id, checked_at: observed };
+ }
if (item.source_key === 'ovhcloud-vps') {
if (item.source_key === 'ovhcloud-vps') {
const plan = ovhcloud(x, id, item.title);
const plan = ovhcloud(x, id, item.title);
return plan && { ...common, ...plan };
return plan && { ...common, ...plan };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * Microsoft Azure's retail price list in the catalog (server/catalog/mapping.js): a virtual machine carries its size,
+ * its region, its price an hour and a month, and the date the price took effect is the observation time the price
+ * trend (catalog_price_changes, Host #210) is dated by.
+ */
+const assert = require('assert');
+const { testDb } = require('./helpers/db');
+const { createStore } = require('../server/db');
+const { createCatalog } = require('../server/catalog/store');
+const { mapItem } = require('../server/catalog/mapping');
+const { check, done } = require('./helpers/boot');
+const at = '2026-10-30T12:00:00Z';
+
+// The shape the Sources `api` adapter stores for an Azure price row (a recorded fixture: no live call here).
+const item = (id, fields, time = at, title = id) => ({
+ id: `itm_${'0'.repeat(26)}`, source_key: 'azure-retail-prices', category: 'hosting', kind: 'plan', identity: id,
+ canonical_url: null, title, summary: null, authors: [], published_at: null, source_updated_at: null, fields, revision: 1,
+ provenance: { retrieved_at: time, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null }, removed: null,
+});
+const vm = (over = {}) => ({ sku_id: 'DZH318Z08M9W/006F', sku_name: 'Standard_D8s_v5', meter_name: 'D8s v5', product_name: 'Virtual Machines Dsv5 Series', service_name: 'Virtual Machines', region: 'eastus', location: 'US East', price: 0.96, unit: '1 Hour', currency: 'USD', effective_start: '2021-11-01T00:00:00Z', tier_minimum_units: 0, ...over });
+
+(async () => {
+ const db = await testDb();
+ const store = createStore(db.db, { now: () => Date.parse(at) });
+ const catalog = createCatalog({ store });
+ try {
+ await check('an Azure virtual machine maps with its size, region, hourly and monthly price', async () => {
+ const p = mapItem(item('DZH318Z08M9W/006F', vm(), at, 'Standard_D8s_v5'));
+ assert.deepStrictEqual([p.provider.slug, p.provider.name, p.name, p.kind, p.locations, p.price_hourly, p.price_monthly, p.currency, p.source_url],
+ ['azure', 'Microsoft Azure', 'Standard_D8s_v5', 'cloud', ['eastus'], 0.96, 700.8, 'USD', 'https://azure.microsoft.com/en-us/pricing/']);
+ assert.strictEqual(p.external_id, 'DZH318Z08M9W/006F@eastus', 'the same SKU in another region is another plan');
+ assert.strictEqual(p.checked_at, Date.parse('2021-11-01T00:00:00Z'), 'the effective date is the observation time');
+ const spot = mapItem(item('DZH318Z08M9W/006G', vm({ meter_name: 'D8s v5 Spot', price: 0.29, sku_id: 'DZH318Z08M9W/006G' }), at, 'Standard_D8s_v5'));
+ assert.strictEqual(spot.name, 'Standard_D8s_v5 (Spot)');
+ const storage = mapItem(item('DZH318Z0BNZJ/00WH', { ...vm(), sku_id: 'DZH318Z0BNZJ/00WH', sku_name: 'Cold LRS', meter_name: 'Cold LRS Data Stored', product_name: 'Blob Storage', service_name: 'Storage', price: 0.0036, unit: '1 GB/Month' }, at, 'Cold LRS'));
+ assert.deepStrictEqual([storage.kind, storage.name, storage.price_monthly, storage.price_hourly], ['storage', 'Cold LRS (per GB/month)', 0.0036, null]);
+ assert.throws(() => mapItem(item('X', vm({ unit: '1 Day' }), at, 'x')), /unknown Azure price unit/);
+ });
+
+ await check('the plan row lands in the catalog with the effective date, and a later price is dated by its own effective date', async () => {
+ await catalog.applyItem(item('DZH318Z08M9W/006F', vm(), at, 'Standard_D8s_v5'));
+ const row = (await catalog.plans({ provider: 'azure' })).plans[0];
+ assert.deepStrictEqual([row.provider_slug, row.name, Number(row.price_monthly), Number(row.price_hourly), row.locations, new Date(row.checked_at).toISOString()],
+ ['azure', 'Standard_D8s_v5', 700.8, 0.96, ['eastus'], '2021-11-01T00:00:00.000Z']);
+ // The price changes and Microsoft says the new price took effect on 2026-06-01: the change is dated then.
+ await catalog.applyItem(item('DZH318Z08M9W/006F', vm({ price: 1.02, effective_start: '2026-06-01T00:00:00Z' }), '2026-10-30T12:00:00Z', 'Standard_D8s_v5'));
+ const plan = await catalog.plan(row.id);
+ assert.strictEqual(Number(plan.price_monthly), 744.6);
+ assert.deepStrictEqual(plan.price_history.map((c) => [new Date(c.observed_at).toISOString().slice(0, 10), Number(c.price_monthly_before), Number(c.price_monthly_after)]),
+ [['2026-06-01', 700.8, 744.6]]);
+ });
+ } finally {
+ await db.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });