openvibers/openvibe.host Commits
Host: Amazon Lightsail in the hosting catalog (aws-lightsail-bundles mapping) (#79)
Changes
@@@ -18,7 +18,10 @@ OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is- **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` and `vultr-plans`. 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`.++ 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.- Staff entries, which must name their source URL.- Nothing is estimated. A value the source does not state is shown as Unknown.- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).@@@ -5,12 +5,49 @@const SOURCES = Object.freeze({'vultr-plans': { slug: 'vultr', name: 'Vultr', homepage_url: 'https://www.vultr.com/', source_url: 'https://www.vultr.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/' },});const number = (v, field) => {if (v == null) return null;if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) throw new TypeError(`${field} must be a non-negative number`);return v;};++// AWS's price list gives every number as text: "2" vCPUs, "0.5GB" of memory, "1TB" of transfer, "0.0067200000" an hour.++const decimal = (v, field) => {++ if (v == null) return null;++ if (typeof v !== 'string' || !/^\d+(\.\d+)?$/.test(v.trim())) throw new TypeError(`${field} must be a decimal`);++ return Number(v.trim());++};++const sizeGb = (v, field) => {++ if (v == null) return null;++ const m = typeof v === 'string' && /^(\d+(?:\.\d+)?)\s*(GB|TB)$/i.exec(v.trim());++ if (!m) throw new TypeError(`${field} must be a size in GB or TB`);++ return Number(m[1]) * (m[2].toUpperCase() === 'TB' ? 1000 : 1);++};++const LIGHTSAIL_FAMILIES = { BundleUsage: 'General purpose', ComputeOptimizedBundleUsage: 'Compute-optimized', MemoryOptimizedBundleUsage: 'Memory-optimized' };++// "USE1-BundleUsage:0.5GB_IPv6" is the general-purpose 0.5 GB bundle without a public IPv4 address.++function lightsailName(usagetype, memoryGb) {++ const m = /^[A-Z0-9]+-([A-Za-z]+):([0-9.]+GB)(_IPv6)?$/.exec(typeof usagetype === 'string' ? usagetype : '');++ if (!m) return null;++ const family = LIGHTSAIL_FAMILIES[m[1]] || m[1];++ return `${family} ${memoryGb != null ? `${memoryGb} GB` : m[2]}${m[3] ? ' (IPv6 only)' : ''}`;++}++function lightsail(x, id) {++ if (x.price_unit !== 'Hrs') throw new TypeError('Lightsail price is not hourly');++ const hourly = decimal(x.price_hourly, 'hourly price');++ if (hourly == null) throw new TypeError('missing hourly price');++ const memoryGb = sizeGb(x.memory, 'memory');++ const ipv6Only = /_IPv6$/.test(x.usagetype || '');++ return {++ name: lightsailName(x.usagetype, memoryGb) || id, kind: 'vps',++ vcpu: decimal(x.vcpu, 'vcpu'), ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024),++ disk_gb: sizeGb(x.storage, 'storage'), disk_type: 'ssd', bandwidth_gb: sizeGb(x.transfer, 'transfer'),++ // Lightsail bills a bundle by the hour up to its monthly price, which is the hourly rate for 744 hours (a 31-day++ // month): 0.00672 an hour is the $5.00 bundle AWS lists.++ price_monthly: Math.round(hourly * 744 * 100) / 100, price_hourly: hourly,++ ipv4: ipv6Only ? 0 : 1, locations: typeof x.location === 'string' && x.location ? [x.location] : null,++ };++}function mapItem(item) {const provider = SOURCES[item && item.source_key];if (!provider) return null;@@@ -18,11 +55,16 @@ function mapItem(item) {const x = item.fields;const id = typeof item.identity === 'string' ? item.identity.trim() : '';if (!id || id.length > 200 || (!Number.isFinite(Date.parse(item.provenance.retrieved_at)) || !Number.isFinite(Date.parse(item.provenance.first_seen_at)))) throw new TypeError('invalid plan identity or fetch time');++ const common = {++ provider, external_id: id, currency: 'USD', source_key: item.source_key, source_item_id: item.id,++ 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) };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');return {-- provider, external_id: id, name: vultr ? id : ((typeof item.title === 'string' && item.title.trim()) || id),++ ...common, name: vultr ? id : ((typeof item.title === 'string' && item.title.trim()) || id),kind: vultr && x.type === 'vdc' ? 'dedicated' : 'cloud',vcpu: number(vultr ? x.vcpu_count : x.vcpus, 'vcpu'),ram_mb: number(vultr ? x.ram : x.memory, 'ram'),@@@ -30,8 +72,7 @@ function mapItem(item) {disk_type: vultr ? (['vhf', 'vhp'].includes(x.type) ? 'nvme' : x.type === 'vc2' ? 'ssd' : null) : null,bandwidth_gb: number(vultr ? x.bandwidth : x.transfer, 'bandwidth'),price_monthly: monthly, price_hourly: number(vultr ? x.hourly_cost : x.price_hourly, 'hourly price'),-- currency: 'USD', source_key: item.source_key, source_item_id: item.id,-- source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),++ ipv4: null, locations: null,};}module.exports = { SOURCES, mapItem };@@@ -113,7 +113,7 @@ function createCatalog({ store, community = null, log = console }) {p = { id };}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', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];++ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', '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) {@@@ -28,12 +28,25 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repeaassert.strictEqual(mapItem(item('vultr-plans', 'vdc-1', { monthly_cost: 50, type: 'vdc' })).kind, 'dedicated');assert.strictEqual(mapItem(item('vultr-plans', 'vhf-1', { monthly_cost: 10, type: 'vhf' })).disk_type, 'nvme');});++ await check('an AWS Lightsail bundle maps from the price list text, with its monthly price from 744 hours', async () => {++ const ls = { usagetype: 'USE1-BundleUsage:0.5GB', vcpu: '2', memory: '0.5GB', storage: '20GB', transfer: '1TB', location: 'US East (N. Virginia)', region: 'us-east-1', price_hourly: '0.0067200000', price_unit: 'Hrs' };++ const p = mapItem(item('aws-lightsail-bundles', '8E4A4X47ZPXKB2KD', ls, at, ls.usagetype));++ assert.deepStrictEqual([p.provider.slug, p.name, p.kind, p.vcpu, p.ram_mb, p.disk_gb, p.disk_type, p.bandwidth_gb, p.price_monthly, p.price_hourly, p.ipv4, p.locations],++ ['aws-lightsail', 'General purpose 0.5 GB', 'vps', 2, 512, 20, 'ssd', 1000, 5, 0.00672, 1, ['US East (N. Virginia)']]);++ const v6 = mapItem(item('aws-lightsail-bundles', 'SKU2', { ...ls, usagetype: 'USE1-ComputeOptimizedBundleUsage:8GB_IPv6', memory: '8GB', price_hourly: '0.1075300000' }));++ assert.deepStrictEqual([v6.name, v6.ipv4, v6.ram_mb, v6.price_monthly], ['Compute-optimized 8 GB (IPv6 only)', 0, 8192, 80]);++ assert.throws(() => mapItem(item('aws-lightsail-bundles', 'SKU3', { ...ls, price_unit: 'Quantity' })), /not hourly/);++ assert.throws(() => mapItem(item('aws-lightsail-bundles', 'SKU4', { ...ls, memory: 'lots' })), /size in GB or TB/);++ await catalog.applyItem(item('aws-lightsail-bundles', '8E4A4X47ZPXKB2KD', ls, at, ls.usagetype));++ const row = (await catalog.plans({ provider: 'aws-lightsail' })).plans[0];++ assert.deepStrictEqual([row.name, Number(row.price_monthly), row.ipv4, row.locations], ['General purpose 0.5 GB', 5, 1, ['US East (N. Virginia)']]);++ });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));await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-12T12:00:00Z'));await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-13T12:00:00Z'));-- const p = (await catalog.plans()).plans[0];++ const p = (await catalog.plans({ provider: 'vultr' })).plans[0];assert.strictEqual(Number(p.price_monthly), 6);assert.strictEqual(p.first_seen_at, Date.parse(at));assert.strictEqual(p.unmetered, null); assert.strictEqual(p.locations, null);
@@ -18,7 +18,10 @@ OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is
@@ -18,7 +18,10 @@ OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is
- **Where plans come from.**
- **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`.
- 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` and `vultr-plans`. 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`.
+ 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.
- Staff entries, which must name their source URL.
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
@@ -5,12 +5,49 @@
@@ -5,12 +5,49 @@
const SOURCES = Object.freeze({
const SOURCES = Object.freeze({
'vultr-plans': { slug: 'vultr', name: 'Vultr', homepage_url: 'https://www.vultr.com/', source_url: 'https://www.vultr.com/pricing/' },
'vultr-plans': { slug: 'vultr', name: 'Vultr', homepage_url: 'https://www.vultr.com/', source_url: 'https://www.vultr.com/pricing/' },
'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/' },
});
});
const number = (v, field) => {
const number = (v, field) => {
if (v == null) return null;
if (v == null) return null;
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) throw new TypeError(`${field} must be a non-negative number`);
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) throw new TypeError(`${field} must be a non-negative number`);
return v;
return v;
};
};
+// AWS's price list gives every number as text: "2" vCPUs, "0.5GB" of memory, "1TB" of transfer, "0.0067200000" an hour.
+const decimal = (v, field) => {
+ if (v == null) return null;
+ if (typeof v !== 'string' || !/^\d+(\.\d+)?$/.test(v.trim())) throw new TypeError(`${field} must be a decimal`);
+ return Number(v.trim());
+};
+const sizeGb = (v, field) => {
+ if (v == null) return null;
+ const m = typeof v === 'string' && /^(\d+(?:\.\d+)?)\s*(GB|TB)$/i.exec(v.trim());
+ if (!m) throw new TypeError(`${field} must be a size in GB or TB`);
+ return Number(m[1]) * (m[2].toUpperCase() === 'TB' ? 1000 : 1);
+};
+const LIGHTSAIL_FAMILIES = { BundleUsage: 'General purpose', ComputeOptimizedBundleUsage: 'Compute-optimized', MemoryOptimizedBundleUsage: 'Memory-optimized' };
+// "USE1-BundleUsage:0.5GB_IPv6" is the general-purpose 0.5 GB bundle without a public IPv4 address.
+function lightsailName(usagetype, memoryGb) {
+ const m = /^[A-Z0-9]+-([A-Za-z]+):([0-9.]+GB)(_IPv6)?$/.exec(typeof usagetype === 'string' ? usagetype : '');
+ if (!m) return null;
+ const family = LIGHTSAIL_FAMILIES[m[1]] || m[1];
+ return `${family} ${memoryGb != null ? `${memoryGb} GB` : m[2]}${m[3] ? ' (IPv6 only)' : ''}`;
+}
+function lightsail(x, id) {
+ if (x.price_unit !== 'Hrs') throw new TypeError('Lightsail price is not hourly');
+ const hourly = decimal(x.price_hourly, 'hourly price');
+ if (hourly == null) throw new TypeError('missing hourly price');
+ const memoryGb = sizeGb(x.memory, 'memory');
+ const ipv6Only = /_IPv6$/.test(x.usagetype || '');
+ return {
+ name: lightsailName(x.usagetype, memoryGb) || id, kind: 'vps',
+ vcpu: decimal(x.vcpu, 'vcpu'), ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024),
+ disk_gb: sizeGb(x.storage, 'storage'), disk_type: 'ssd', bandwidth_gb: sizeGb(x.transfer, 'transfer'),
+ // Lightsail bills a bundle by the hour up to its monthly price, which is the hourly rate for 744 hours (a 31-day
+ // month): 0.00672 an hour is the $5.00 bundle AWS lists.
+ price_monthly: Math.round(hourly * 744 * 100) / 100, price_hourly: hourly,
+ ipv4: ipv6Only ? 0 : 1, locations: typeof x.location === 'string' && x.location ? [x.location] : null,
+ };
+}
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;
@@ -18,11 +55,16 @@ function mapItem(item) {
@@ -18,11 +55,16 @@ function mapItem(item) {
const x = item.fields;
const x = item.fields;
const id = typeof item.identity === 'string' ? item.identity.trim() : '';
const id = typeof item.identity === 'string' ? item.identity.trim() : '';
if (!id || id.length > 200 || (!Number.isFinite(Date.parse(item.provenance.retrieved_at)) || !Number.isFinite(Date.parse(item.provenance.first_seen_at)))) throw new TypeError('invalid plan identity or fetch time');
if (!id || id.length > 200 || (!Number.isFinite(Date.parse(item.provenance.retrieved_at)) || !Number.isFinite(Date.parse(item.provenance.first_seen_at)))) throw new TypeError('invalid plan identity or fetch time');
+ const common = {
+ provider, external_id: id, currency: 'USD', source_key: item.source_key, source_item_id: item.id,
+ 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) };
const vultr = item.source_key === 'vultr-plans';
const vultr = item.source_key === 'vultr-plans';
const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');
const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');
if (monthly == null) throw new TypeError('missing monthly price');
if (monthly == null) throw new TypeError('missing monthly price');
return {
return {
- provider, external_id: id, name: vultr ? id : ((typeof item.title === 'string' && item.title.trim()) || id),
+ ...common, name: vultr ? id : ((typeof item.title === 'string' && item.title.trim()) || id),
kind: vultr && x.type === 'vdc' ? 'dedicated' : 'cloud',
kind: vultr && x.type === 'vdc' ? 'dedicated' : 'cloud',
vcpu: number(vultr ? x.vcpu_count : x.vcpus, 'vcpu'),
vcpu: number(vultr ? x.vcpu_count : x.vcpus, 'vcpu'),
ram_mb: number(vultr ? x.ram : x.memory, 'ram'),
ram_mb: number(vultr ? x.ram : x.memory, 'ram'),
@@ -30,8 +72,7 @@ function mapItem(item) {
@@ -30,8 +72,7 @@ function mapItem(item) {
disk_type: vultr ? (['vhf', 'vhp'].includes(x.type) ? 'nvme' : x.type === 'vc2' ? 'ssd' : null) : null,
disk_type: vultr ? (['vhf', 'vhp'].includes(x.type) ? 'nvme' : x.type === 'vc2' ? 'ssd' : null) : null,
bandwidth_gb: number(vultr ? x.bandwidth : x.transfer, 'bandwidth'),
bandwidth_gb: number(vultr ? x.bandwidth : x.transfer, 'bandwidth'),
price_monthly: monthly, price_hourly: number(vultr ? x.hourly_cost : x.price_hourly, 'hourly price'),
price_monthly: monthly, price_hourly: number(vultr ? x.hourly_cost : x.price_hourly, 'hourly price'),
- currency: 'USD', source_key: item.source_key, source_item_id: item.id,
- source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
+ ipv4: null, locations: null,
};
};
}
}
module.exports = { SOURCES, mapItem };
module.exports = { SOURCES, mapItem };
@@ -113,7 +113,7 @@ function createCatalog({ store, community = null, log = console }) {
@@ -113,7 +113,7 @@ function createCatalog({ store, community = null, log = console }) {
p = { id };
p = { id };
}
}
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
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', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
+ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
if (old) {
if (old) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
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) {
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
@@ -28,12 +28,25 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
@@ -28,12 +28,25 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
assert.strictEqual(mapItem(item('vultr-plans', 'vdc-1', { monthly_cost: 50, type: 'vdc' })).kind, 'dedicated');
assert.strictEqual(mapItem(item('vultr-plans', 'vdc-1', { monthly_cost: 50, type: 'vdc' })).kind, 'dedicated');
assert.strictEqual(mapItem(item('vultr-plans', 'vhf-1', { monthly_cost: 10, type: 'vhf' })).disk_type, 'nvme');
assert.strictEqual(mapItem(item('vultr-plans', 'vhf-1', { monthly_cost: 10, type: 'vhf' })).disk_type, 'nvme');
});
});
+ await check('an AWS Lightsail bundle maps from the price list text, with its monthly price from 744 hours', async () => {
+ const ls = { usagetype: 'USE1-BundleUsage:0.5GB', vcpu: '2', memory: '0.5GB', storage: '20GB', transfer: '1TB', location: 'US East (N. Virginia)', region: 'us-east-1', price_hourly: '0.0067200000', price_unit: 'Hrs' };
+ const p = mapItem(item('aws-lightsail-bundles', '8E4A4X47ZPXKB2KD', ls, at, ls.usagetype));
+ assert.deepStrictEqual([p.provider.slug, p.name, p.kind, p.vcpu, p.ram_mb, p.disk_gb, p.disk_type, p.bandwidth_gb, p.price_monthly, p.price_hourly, p.ipv4, p.locations],
+ ['aws-lightsail', 'General purpose 0.5 GB', 'vps', 2, 512, 20, 'ssd', 1000, 5, 0.00672, 1, ['US East (N. Virginia)']]);
+ const v6 = mapItem(item('aws-lightsail-bundles', 'SKU2', { ...ls, usagetype: 'USE1-ComputeOptimizedBundleUsage:8GB_IPv6', memory: '8GB', price_hourly: '0.1075300000' }));
+ assert.deepStrictEqual([v6.name, v6.ipv4, v6.ram_mb, v6.price_monthly], ['Compute-optimized 8 GB (IPv6 only)', 0, 8192, 80]);
+ assert.throws(() => mapItem(item('aws-lightsail-bundles', 'SKU3', { ...ls, price_unit: 'Quantity' })), /not hourly/);
+ assert.throws(() => mapItem(item('aws-lightsail-bundles', 'SKU4', { ...ls, memory: 'lots' })), /size in GB or TB/);
+ await catalog.applyItem(item('aws-lightsail-bundles', '8E4A4X47ZPXKB2KD', ls, at, ls.usagetype));
+ const row = (await catalog.plans({ provider: 'aws-lightsail' })).plans[0];
+ assert.deepStrictEqual([row.name, Number(row.price_monthly), row.ipv4, row.locations], ['General purpose 0.5 GB', 5, 1, ['US East (N. Virginia)']]);
+ });
await check('upsert preserves first seen and records only a changed price', async () => {
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' };
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));
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', raw));
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-12T12:00:00Z'));
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-12T12:00:00Z'));
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-13T12:00:00Z'));
await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-13T12:00:00Z'));
- const p = (await catalog.plans()).plans[0];
+ const p = (await catalog.plans({ provider: 'vultr' })).plans[0];
assert.strictEqual(Number(p.price_monthly), 6);
assert.strictEqual(Number(p.price_monthly), 6);
assert.strictEqual(p.first_seen_at, Date.parse(at));
assert.strictEqual(p.first_seen_at, Date.parse(at));
assert.strictEqual(p.unmetered, null); assert.strictEqual(p.locations, null);
assert.strictEqual(p.unmetered, null); assert.strictEqual(p.locations, null);