`npm test` did nothing until now. CI that runs no tests is theatre, so the tests came first — 39 of them, over the two places where an error would be silent and expensive. packages/core: the margin arithmetic. Every dashboard figure, idle-capacity alert and agent answer resolves through it, and wrong numbers still look like numbers. The cases pin decisions rather than implementation: cost is charged against the full commitment (a naive version reports the opposite sign on a loss-making block), aggregation sums cents rather than averaging percentages (averaging reports +22% on a book that is losing money), break-even prices the remaining hours and returns null rather than Infinity when there are none, and internal research burn counts as cost with no revenue. packages/prime: the upstream mapping. Rounding rather than truncating cents, because 2.43 is 2.4299999 in binary and a lost cent compounds across millions of GPU-hours. And interconnect normalisation, where an unrecognised fabric maps to Unknown rather than Ethernet — guessing low loses a deal, guessing high sells a training customer a cluster that cannot train. CI runs on push and pull request: typecheck all six packages, unit tests, migrations applied twice to a real Postgres, a seed-idempotency assertion that fails the build if row counts move on a second run, a server boot, the front-end build, and a Docker build. It also asserts the inline theme script's hash still matches the CSP the proxy allows. That script prevents a white flash for dark-mode users; if it changes without the CSP being updated, the browser silently blocks it and nothing anywhere reports an error. Deployment stays a script rather than push-to-deploy. Automating it would put an SSH key with production write access on the CI runner — a real escalation for a project this size. The script takes a database dump before migrating and refuses to finish if an unauthenticated request returns anything but 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
# Continuous integration.
|
||||||
|
#
|
||||||
|
# Runs on every push and pull request. The job is deliberately one sequence
|
||||||
|
# rather than a fan-out: this is a small project, the whole thing takes a
|
||||||
|
# couple of minutes, and a single log is easier to read than five.
|
||||||
|
#
|
||||||
|
# What it actually proves, in order of how likely each is to catch something:
|
||||||
|
#
|
||||||
|
# 1. Every package typechecks.
|
||||||
|
# 2. The migration chain applies to a REAL, empty Postgres. This has already
|
||||||
|
# caught one migration that Drizzle generated but Postgres refused
|
||||||
|
# (a jsonb -> integer cast with no USING clause).
|
||||||
|
# 3. The seed is idempotent — running it twice leaves the same row counts.
|
||||||
|
# This caught a seed that silently duplicated 27 contacts.
|
||||||
|
# 4. The unit tests pass.
|
||||||
|
# 5. The server boots against that database and answers.
|
||||||
|
# 6. The front end builds, and the CSP hash for the inline theme script still
|
||||||
|
# matches what the proxy is configured to allow. Editing that script
|
||||||
|
# changes its hash, and the failure mode is a silent white flash for
|
||||||
|
# dark-mode users rather than an error.
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: pig
|
||||||
|
POSTGRES_PASSWORD: pig
|
||||||
|
POSTGRES_DB: pig
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U pig"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgres://pig:pig@postgres:5432/pig
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Typecheck every package
|
||||||
|
run: |
|
||||||
|
npx tsc --noEmit -p packages/core/tsconfig.json
|
||||||
|
npx tsc --noEmit -p packages/db/tsconfig.json
|
||||||
|
npx tsc --noEmit -p packages/prime/tsconfig.json
|
||||||
|
npx tsc --noEmit -p apps/api/tsconfig.json
|
||||||
|
npx tsc --noEmit -p apps/web/tsconfig.json
|
||||||
|
npx tsc --noEmit -p apps/mcp/tsconfig.json
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test --workspaces --if-present
|
||||||
|
|
||||||
|
- name: Migrations apply to a real Postgres
|
||||||
|
run: npx tsx packages/db/src/migrate.ts
|
||||||
|
|
||||||
|
- name: Migrations are re-runnable
|
||||||
|
run: npx tsx packages/db/src/migrate.ts
|
||||||
|
|
||||||
|
- name: Seed is idempotent
|
||||||
|
# A seed that duplicates on a second run corrupts any database it is
|
||||||
|
# pointed at twice, and nobody notices until the counts look odd.
|
||||||
|
run: |
|
||||||
|
npx tsx packages/db/src/seed/index.ts > /dev/null
|
||||||
|
BEFORE=$(psql "$DATABASE_URL" -tAc "select count(*) from contacts")
|
||||||
|
npx tsx packages/db/src/seed/index.ts > /dev/null
|
||||||
|
AFTER=$(psql "$DATABASE_URL" -tAc "select count(*) from contacts")
|
||||||
|
echo "contacts: $BEFORE -> $AFTER"
|
||||||
|
test "$BEFORE" = "$AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
|
||||||
|
|
||||||
|
- name: Server boots and answers
|
||||||
|
run: |
|
||||||
|
NODE_ENV=development PIG_PORT=8930 npx tsx apps/api/src/server.ts &
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -sf http://127.0.0.1:8930/api/health && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -sf http://127.0.0.1:8930/api/health | grep -q '"ok":true'
|
||||||
|
|
||||||
|
- name: Front end builds
|
||||||
|
run: npm run build -w @pig/web
|
||||||
|
|
||||||
|
- name: Inline theme script still matches the deployed CSP hash
|
||||||
|
# The proxy allows exactly one inline script by hash. If the script
|
||||||
|
# changes and the CSP is not updated, dark-mode users get a white flash
|
||||||
|
# on every load and nothing anywhere reports an error.
|
||||||
|
run: |
|
||||||
|
node -e "
|
||||||
|
const fs=require('fs'), crypto=require('crypto');
|
||||||
|
const html=fs.readFileSync('apps/web/dist/index.html','utf8');
|
||||||
|
const m=html.match(/<script>([\s\S]*?)<\/script>/);
|
||||||
|
if(!m){ console.error('No inline script found in index.html'); process.exit(1); }
|
||||||
|
const hash='sha256-'+crypto.createHash('sha256').update(m[1]).digest('base64');
|
||||||
|
const expected='sha256-1tTDwCq+TCEyPDSZeYqW5HbmP+unUg8hrgRiZBiH/IU=';
|
||||||
|
if(hash!==expected){
|
||||||
|
console.error('Inline script hash changed.');
|
||||||
|
console.error(' now: '+hash);
|
||||||
|
console.error(' expected: '+expected);
|
||||||
|
console.error('Update the CSP in deploy/Caddyfile.example AND on the server,');
|
||||||
|
console.error('then update the expected hash in this workflow.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('CSP hash unchanged: '+hash);
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Docker image builds
|
||||||
|
run: docker build -t pig:ci .
|
||||||
@@ -18,3 +18,4 @@ coverage/
|
|||||||
|
|
||||||
# Postgres volume mounts used by local compose
|
# Postgres volume mounts used by local compose
|
||||||
deploy/pgdata/
|
deploy/pgdata/
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --test --import tsx test/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the margin arithmetic.
|
||||||
|
*
|
||||||
|
* This is the most load-bearing pure code in the project: every dashboard
|
||||||
|
* figure, every idle-capacity alert and every answer an agent gives resolves
|
||||||
|
* through these functions. An error here is an error everywhere, and it would
|
||||||
|
* be a quiet one — wrong numbers still look like numbers.
|
||||||
|
*
|
||||||
|
* The cases below are chosen to pin the *decisions*, not merely the
|
||||||
|
* implementation. Several would pass under a naive version that is wrong in a
|
||||||
|
* way that flatters the business, which is precisely why they exist.
|
||||||
|
*/
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import {
|
||||||
|
aggregateMargin,
|
||||||
|
breakEvenPricePerGpuHourCents,
|
||||||
|
computeMargin,
|
||||||
|
formatCents,
|
||||||
|
} from '../src/margin';
|
||||||
|
|
||||||
|
describe('computeMargin', () => {
|
||||||
|
it('charges cost against the FULL commitment, not only the hours sold', () => {
|
||||||
|
// The decision the whole product rests on. A naive implementation charges
|
||||||
|
// only the allocated share and reports this block as profitable, when in
|
||||||
|
// fact it loses money: the unsold hours were already paid for.
|
||||||
|
const result = computeMargin(
|
||||||
|
{ gpuHours: 1000, costPerGpuHourCents: 100 },
|
||||||
|
[{ gpuHours: 500, pricePerGpuHourCents: 150 }],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.costCents, 100_000, 'cost must cover all 1000 hours');
|
||||||
|
assert.equal(result.revenueCents, 75_000);
|
||||||
|
assert.equal(result.grossMarginCents, -25_000, 'this block is underwater');
|
||||||
|
|
||||||
|
// Under the naive treatment: cost 500×100 = 50,000 against revenue 75,000,
|
||||||
|
// reporting +25,000 — a sign error on the thing that matters most.
|
||||||
|
assert.ok(result.grossMarginCents < 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports utilisation and idle hours', () => {
|
||||||
|
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 700, pricePerGpuHourCents: 200 },
|
||||||
|
]);
|
||||||
|
assert.equal(r.allocatedGpuHours, 700);
|
||||||
|
assert.equal(r.idleGpuHours, 300);
|
||||||
|
assert.equal(r.utilisation, 0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums several allocations against one commitment', () => {
|
||||||
|
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 400, pricePerGpuHourCents: 200 },
|
||||||
|
{ gpuHours: 300, pricePerGpuHourCents: 250 },
|
||||||
|
]);
|
||||||
|
assert.equal(r.allocatedGpuHours, 700);
|
||||||
|
assert.equal(r.revenueCents, 400 * 200 + 300 * 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts internal consumption as cost with no revenue', () => {
|
||||||
|
// Research burn is a zero-price allocation. Leaving it out would overstate
|
||||||
|
// available capacity; pricing it at anything but zero would invent revenue.
|
||||||
|
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 600, pricePerGpuHourCents: 200 },
|
||||||
|
{ gpuHours: 200, pricePerGpuHourCents: 0 },
|
||||||
|
]);
|
||||||
|
assert.equal(r.allocatedGpuHours, 800);
|
||||||
|
assert.equal(r.revenueCents, 120_000, 'the internal 200 hours earn nothing');
|
||||||
|
assert.equal(r.idleGpuHours, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps idle at zero when capacity is deliberately oversubscribed', () => {
|
||||||
|
// Selling beyond 100% is a real policy, not a data-entry error. Negative
|
||||||
|
// idle hours would be nonsense to display.
|
||||||
|
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 1200, pricePerGpuHourCents: 150 },
|
||||||
|
]);
|
||||||
|
assert.equal(r.idleGpuHours, 0);
|
||||||
|
assert.equal(r.utilisation, 1.2, 'utilisation itself is not clamped — it is the signal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null rather than dividing by zero on an empty book', () => {
|
||||||
|
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, []);
|
||||||
|
assert.equal(r.revenueCents, 0);
|
||||||
|
assert.equal(r.grossMarginCents, -100_000);
|
||||||
|
assert.equal(r.grossMarginPct, null, 'no revenue means no percentage, not Infinity');
|
||||||
|
assert.equal(r.marginPerAllocatedGpuHourCents, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a zero-hour commitment without producing NaN', () => {
|
||||||
|
const r = computeMargin({ gpuHours: 0, costPerGpuHourCents: 100 }, []);
|
||||||
|
assert.equal(r.utilisation, 0);
|
||||||
|
assert.ok(!Number.isNaN(r.utilisation));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps money in whole cents', () => {
|
||||||
|
// Fractional hours are normal; fractional cents are not. A dashboard that
|
||||||
|
// renders 1234.9999999 has lost the plot.
|
||||||
|
const r = computeMargin({ gpuHours: 0.1, costPerGpuHourCents: 3 }, [
|
||||||
|
{ gpuHours: 0.1, pricePerGpuHourCents: 7 },
|
||||||
|
]);
|
||||||
|
assert.ok(Number.isInteger(r.costCents));
|
||||||
|
assert.ok(Number.isInteger(r.revenueCents));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('breakEvenPricePerGpuHourCents', () => {
|
||||||
|
it('prices the REMAINING hours, not the whole block', () => {
|
||||||
|
// Half a 1000-hour block at 100c cost is sold at 120c: 60,000 of 100,000
|
||||||
|
// recovered, 40,000 outstanding across 500 remaining hours = 80c.
|
||||||
|
const price = breakEvenPricePerGpuHourCents({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 500, pricePerGpuHourCents: 120 },
|
||||||
|
]);
|
||||||
|
assert.equal(price, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls as more of the block sells', () => {
|
||||||
|
const commitment = { gpuHours: 1000, costPerGpuHourCents: 100 };
|
||||||
|
const early = breakEvenPricePerGpuHourCents(commitment, [
|
||||||
|
{ gpuHours: 200, pricePerGpuHourCents: 120 },
|
||||||
|
])!;
|
||||||
|
const later = breakEvenPricePerGpuHourCents(commitment, [
|
||||||
|
{ gpuHours: 800, pricePerGpuHourCents: 120 },
|
||||||
|
])!;
|
||||||
|
assert.ok(later < early, 'a mostly-sold block is cheaper to break even on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero once cost is covered — further sales are pure upside', () => {
|
||||||
|
const price = breakEvenPricePerGpuHourCents({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 500, pricePerGpuHourCents: 300 },
|
||||||
|
]);
|
||||||
|
// Not a negative price, which would be meaningless to show a seller.
|
||||||
|
assert.equal(price, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing is left to sell', () => {
|
||||||
|
const price = breakEvenPricePerGpuHourCents({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
|
||||||
|
{ gpuHours: 1000, pricePerGpuHourCents: 150 },
|
||||||
|
]);
|
||||||
|
assert.equal(price, null, 'no remaining hours means no break-even price, not Infinity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregateMargin', () => {
|
||||||
|
it('sums cents rather than averaging percentages', () => {
|
||||||
|
// A tiny block at a wonderful margin next to a huge one at a terrible
|
||||||
|
// margin. Averaging the two percentages would report roughly break-even;
|
||||||
|
// the truth is a large loss dominated by the big block.
|
||||||
|
const result = aggregateMargin([
|
||||||
|
{
|
||||||
|
commitment: { gpuHours: 10, costPerGpuHourCents: 100 },
|
||||||
|
allocations: [{ gpuHours: 10, pricePerGpuHourCents: 1000 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commitment: { gpuHours: 10_000, costPerGpuHourCents: 100 },
|
||||||
|
allocations: [{ gpuHours: 5_000, pricePerGpuHourCents: 110 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.costCents, 10 * 100 + 10_000 * 100);
|
||||||
|
assert.equal(result.revenueCents, 10 * 1000 + 5_000 * 110);
|
||||||
|
assert.ok(result.grossMarginCents < 0, 'the big block dominates');
|
||||||
|
|
||||||
|
// The naive mean of +90% and -45% is about +22%, which is the wrong sign.
|
||||||
|
assert.ok(result.grossMarginPct! < 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty-safe', () => {
|
||||||
|
const r = aggregateMargin([]);
|
||||||
|
assert.equal(r.costCents, 0);
|
||||||
|
assert.equal(r.grossMarginPct, null);
|
||||||
|
assert.equal(r.utilisation, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCents', () => {
|
||||||
|
it('renders whole currency from integer cents', () => {
|
||||||
|
assert.equal(formatCents(123_456), '$1,234.56');
|
||||||
|
assert.equal(formatCents(0), '$0.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders negatives, which is the case that matters', () => {
|
||||||
|
assert.ok(formatCents(-5000).includes('50'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": { "noEmit": true, "types": ["node"] },
|
||||||
"rootDir": "./src",
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
"outDir": "./dist",
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": {
|
||||||
"scripts": { "typecheck": "tsc --noEmit" },
|
".": "./src/index.ts"
|
||||||
"dependencies": { "@pig/core": "*" }
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --test --import tsx test/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pig/core": "*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the upstream-to-PIG mapping.
|
||||||
|
*
|
||||||
|
* Two things here are worth defending with tests, because getting either wrong
|
||||||
|
* is expensive and neither would throw:
|
||||||
|
*
|
||||||
|
* **Money.** Upstream sends floating-point dollars; PIG stores integer
|
||||||
|
* cents. Truncating instead of rounding loses a cent on values that cannot
|
||||||
|
* be represented exactly in binary, and a cent compounds across millions of
|
||||||
|
* GPU-hours.
|
||||||
|
*
|
||||||
|
* **Interconnect.** The field that decides whether capacity can train or
|
||||||
|
* only serve. Guessing wrong in either direction loses a deal or sells a
|
||||||
|
* customer a cluster that cannot do the job.
|
||||||
|
*/
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { mapListing, toCents } from '../src/map';
|
||||||
|
import type { PrimeGpuListing } from '../src/client';
|
||||||
|
|
||||||
|
const listing = (over: Partial<PrimeGpuListing> = {}): PrimeGpuListing => ({
|
||||||
|
gpuType: 'H100_80GB',
|
||||||
|
gpuCount: 8,
|
||||||
|
raw: {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toCents', () => {
|
||||||
|
it('rounds rather than truncating', () => {
|
||||||
|
// 2.43 is 2.4299999... in binary. Truncating gives 242 and loses a cent
|
||||||
|
// on every single listing.
|
||||||
|
assert.equal(toCents(2.43), 243);
|
||||||
|
assert.equal(toCents(0.47), 47);
|
||||||
|
assert.equal(toCents(12.005), 1201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through null and rejects nonsense', () => {
|
||||||
|
assert.equal(toCents(null), null);
|
||||||
|
assert.equal(toCents(undefined), null);
|
||||||
|
assert.equal(toCents(Number.NaN), null);
|
||||||
|
assert.equal(toCents(Number.POSITIVE_INFINITY), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero, which is a real price and not a missing one', () => {
|
||||||
|
assert.equal(toCents(0), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapListing — interconnect', () => {
|
||||||
|
const cases: [string, string][] = [
|
||||||
|
['Infiniband', 'Infiniband'],
|
||||||
|
['InfiniBand', 'Infiniband'],
|
||||||
|
['INFINIBAND', 'Infiniband'],
|
||||||
|
['infini_band', 'Infiniband'],
|
||||||
|
['IB', 'Infiniband'],
|
||||||
|
['RoCE', 'RoCE'],
|
||||||
|
['roce v2', 'RoCE'],
|
||||||
|
['NVLink', 'NVLink'],
|
||||||
|
['nvl', 'NVLink'],
|
||||||
|
['Ethernet', 'Ethernet'],
|
||||||
|
['eth', 'Ethernet'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [input, expected] of cases) {
|
||||||
|
it(`normalises "${input}" to ${expected}`, () => {
|
||||||
|
assert.equal(mapListing(listing({ interconnectType: input }))!.interconnectType, expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps an unrecognised fabric to Unknown, never to Ethernet', () => {
|
||||||
|
// Assuming Ethernet would understate real capacity; assuming InfiniBand
|
||||||
|
// would sell a training customer a cluster that cannot train. Neither
|
||||||
|
// error is acceptable, so it stays explicitly unknown.
|
||||||
|
assert.equal(mapListing(listing({ interconnectType: 'Omni-Path' }))!.interconnectType, 'Unknown');
|
||||||
|
assert.equal(mapListing(listing({ interconnectType: '' }))!.interconnectType, 'Unknown');
|
||||||
|
assert.equal(mapListing(listing({ interconnectType: undefined }))!.interconnectType, 'Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapListing — stock', () => {
|
||||||
|
it('treats an unrecognised stock signal as Unavailable', () => {
|
||||||
|
// Under-promising inventory is recoverable. A seller offering capacity
|
||||||
|
// that turns out not to exist is not.
|
||||||
|
assert.equal(mapListing(listing({ stockStatus: 'wat' }))!.stockStatus, 'Unavailable');
|
||||||
|
assert.equal(mapListing(listing({ stockStatus: undefined }))!.stockStatus, 'Unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes known values through, case-insensitively', () => {
|
||||||
|
assert.equal(mapListing(listing({ stockStatus: 'available' }))!.stockStatus, 'Available');
|
||||||
|
assert.equal(mapListing(listing({ stockStatus: 'HIGH' }))!.stockStatus, 'High');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapListing — general', () => {
|
||||||
|
it('drops a listing with no GPU type or count — there is nothing sellable', () => {
|
||||||
|
assert.equal(mapListing(listing({ gpuType: undefined })), null);
|
||||||
|
assert.equal(mapListing(listing({ gpuCount: undefined })), null);
|
||||||
|
assert.equal(mapListing(listing({ gpuCount: 0 })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts prices to integer cents', () => {
|
||||||
|
const m = mapListing(listing({ prices: { onDemand: 2.43, communityPrice: 0.94 } }))!;
|
||||||
|
assert.equal(m.onDemandPriceCents, 243);
|
||||||
|
assert.equal(m.communityPriceCents, 94);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the secure tier unless community is stated', () => {
|
||||||
|
// Mislabelling community capacity as secure would let it be sold against a
|
||||||
|
// requirement it cannot meet.
|
||||||
|
assert.equal(mapListing(listing({}))!.securityTier, 'secure_cloud');
|
||||||
|
assert.equal(
|
||||||
|
mapListing(listing({ security: 'community_cloud' }))!.securityTier,
|
||||||
|
'community_cloud',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises sockets and drops unknown ones rather than inventing a value', () => {
|
||||||
|
assert.equal(mapListing(listing({ socket: 'sxm5' }))!.socket, 'SXM5');
|
||||||
|
assert.equal(mapListing(listing({ socket: 'pcie' }))!.socket, 'PCIe');
|
||||||
|
assert.equal(mapListing(listing({ socket: 'SXM_5' }))!.socket, 'SXM5');
|
||||||
|
// The column is an enum; an unmapped value would fail the insert.
|
||||||
|
assert.equal(mapListing(listing({ socket: 'weird' }))!.socket, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps counts sent as objects', () => {
|
||||||
|
const m = mapListing(listing({ vcpu: { defaultCount: 96 }, memory: 1024 }))!;
|
||||||
|
assert.equal(m.vcpu, 96);
|
||||||
|
assert.equal(m.memoryGb, 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the raw payload so a new upstream field is not lost', () => {
|
||||||
|
const raw = { gpuType: 'H100_80GB', gpuCount: 8, somethingNew: 'value' };
|
||||||
|
const m = mapListing({ ...listing(), raw })!;
|
||||||
|
assert.equal((m.raw as Record<string, unknown>).somethingNew, 'value');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": { "noEmit": true },
|
"compilerOptions": { "noEmit": true, "types": ["node"] },
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Deploy PIG. Run on the host that serves it.
|
||||||
|
#
|
||||||
|
# ./scripts/deploy.sh
|
||||||
|
#
|
||||||
|
# Deliberately a script rather than automated push-to-deploy. Automating it
|
||||||
|
# would mean putting an SSH key with write access to the production host onto
|
||||||
|
# the CI runner, which is a meaningful escalation for a project this size. CI
|
||||||
|
# proves the commit is sound; a human decides when it ships.
|
||||||
|
#
|
||||||
|
# Safe to re-run. Migrations are additive and tracked.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "==> Fetching"
|
||||||
|
git fetch -q origin
|
||||||
|
BEFORE=$(git rev-parse --short HEAD)
|
||||||
|
git reset --hard -q origin/main
|
||||||
|
AFTER=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
if [ "$BEFORE" = "$AFTER" ]; then
|
||||||
|
echo " Already at $AFTER"
|
||||||
|
else
|
||||||
|
echo " $BEFORE -> $AFTER"
|
||||||
|
git --no-pager log --oneline "$BEFORE..$AFTER" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Backing up the database first"
|
||||||
|
# Cheap insurance. A migration that goes wrong on a database holding real deal
|
||||||
|
# data is not something to discover without a dump in hand.
|
||||||
|
mkdir -p backups
|
||||||
|
BACKUP="backups/pig-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||||
|
sudo docker compose -p pig exec -T db pg_dump -U pig pig | gzip > "$BACKUP"
|
||||||
|
echo " $BACKUP ($(du -h "$BACKUP" | cut -f1))"
|
||||||
|
|
||||||
|
echo "==> Building and starting"
|
||||||
|
sudo docker compose -p pig up -d --build
|
||||||
|
|
||||||
|
echo "==> Waiting for health"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Migrating"
|
||||||
|
sudo docker compose -p pig exec -T app npx tsx packages/db/src/migrate.ts
|
||||||
|
|
||||||
|
echo "==> Verifying"
|
||||||
|
if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
|
||||||
|
echo " health ok"
|
||||||
|
else
|
||||||
|
echo " HEALTH CHECK FAILED"
|
||||||
|
sudo docker compose -p pig logs app --tail 40
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Authentication must be enforced. A deploy that accidentally serves the CRM
|
||||||
|
# unauthenticated is the one failure worth blocking on.
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8920/api/dashboard)
|
||||||
|
if [ "$CODE" != "401" ]; then
|
||||||
|
echo " UNAUTHENTICATED REQUEST RETURNED $CODE, EXPECTED 401"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " auth enforced"
|
||||||
|
|
||||||
|
echo "==> Deployed $AFTER"
|
||||||
Reference in New Issue
Block a user