diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 96c0189..8a96424 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -89,3 +89,63 @@ export async function computeAlwaysOn(mode: string, serviceName: string | undefi const on = res.body.service?.always_on info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default)'}`) } + +// ---- limits (the resource ceiling; paid plans) ---- + +// Parse a human memory value into MB: "512", "512mb", "1gb", "2g", "1.5gb". +// Exported for unit tests — this is the only place a user-typed size becomes a number. +export function parseMemoryMb(raw: string): number { + const m = /^\s*(\d+(?:\.\d+)?)\s*(g|gb|gi|gib|m|mb|mi|mib)?\s*$/i.exec(raw) + if (!m) throw new Error(`invalid memory: ${raw} (try 512mb, 1gb, 2gb)`) + const n = Number(m[1]) + const unit = (m[2] ?? 'mb').toLowerCase() + const mb = unit.startsWith('g') ? n * 1024 : n + if (!(mb > 0)) throw new Error(`invalid memory: ${raw}`) + return Math.round(mb) +} + +// Whole and half GB collapse (1536 → "1.5 GB"); anything else stays exact in MB — a display that +// rounds 1536 to "2 GB" claims a ceiling the API did not set. +export const fmtMb = (mb: number) => (mb >= 1024 && mb % 512 === 0 ? `${mb / 1024} GB` : `${mb} MB`) + +// The --cpu override, through a throwing parser like every other user-typed number in this repo +// (parseCount, parseMemoryMb). A bare Number() turns a typo into NaN, which JSON.stringify +// serializes as null — the server then sees {cpu: null} instead of the user seeing an error. +// Enforces the provider grid the help text advertises: the server would reject 100 anyway, but a +// value the client KNOWS is invalid should fail locally, matching what --help promises. +const CPU_SIZES = [1, 2, 4, 6, 8] +export function parseCpu(raw: string): number { + const n = Number(raw) + if (!CPU_SIZES.includes(n)) throw new Error(`invalid cpu: ${raw} (provider sizes: ${CPU_SIZES.join(', ')})`) + return n +} + +type LimitsOpts = LifeOpts & { cpu?: string; memory?: string } + +// Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the +// plan cap (so `insta compute limits` is a safe read), which is also what a UI renders as a slider +// with its plan-limit marker. +export async function computeLimits(serviceName: string | undefined, opts: LimitsOpts): Promise { + const api = await ApiClient.load() + const p = await requireProject() + const branch = opts.branch ?? p.branch + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const id = resolveComputeServiceId(services, serviceName) + + if (!opts.memory && !opts.cpu) { + const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/limits`) + if (opts.json) return printJson(r) + info(`compute ${serviceName ?? id}: ceiling ${r.limits.cpu} vCPU / ${fmtMb(r.limits.memoryMb)} (plan max ${r.cap.cpu} vCPU / ${fmtMb(r.cap.memoryMb)})`) + info(' billing is actual usage — the ceiling caps what the app may burn, it is not a price') + return + } + if (!opts.memory) throw new Error('--memory is required when setting limits (cpu is derived from it; pass --cpu only to override)') + + const body: Record = { memoryMb: parseMemoryMb(opts.memory) } + if (opts.cpu) body.cpu = parseCpu(opts.cpu) + const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/limits`, body) + if (handleApproval(res)) return + if (opts.json) return printJson(res.body) + const l = res.body.limits + info(`compute ${res.body.service?.name ?? id}: ceiling set to ${l.cpu} vCPU / ${fmtMb(l.memoryMb)}`) +} diff --git a/src/commands/db.ts b/src/commands/db.ts index 98f775a..7bfd593 100644 --- a/src/commands/db.ts +++ b/src/commands/db.ts @@ -1,4 +1,4 @@ -import { ApiClient, requireProject } from '../api.js' +import { ApiClient, ApiError, requireProject } from '../api.js' import { info, printJson, handleApproval } from '../util.js' type Opts = { branch?: string; group?: string; json?: boolean } @@ -22,3 +22,97 @@ export async function dbAlwaysOn(mode: string, opts: Opts): Promise { const s2z = res.body?.scaleToZero info(`postgres ${opts.group ?? 'default'}: always-on ${s2z === false ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default; first connection after idle cold-starts)'}`) } + +// Validated pass-throughs for the provider's quantity strings. The insta-db resize API takes +// k8s-style quantities (cpu: "2", "2500m"; memory: "4Gi", "2048Mi"), so unlike the compute path +// there is no unit conversion here — but junk must still fail LOCALLY with an example, not travel +// to the server as-is. CASE-EXACT deliberately: k8s quantities are case-sensitive ("4gi" is +// rejected server-side), and local validation that accepts a form the backend refuses would +// defeat its own purpose. +export function parseDbCpu(raw: string): string { + if (!/^\d+(\.\d+)?m?$/.test(raw.trim())) throw new Error(`invalid cpu: ${raw} (try 2, 4, or 2500m)`) + return raw.trim() +} +export function parseDbMemory(raw: string): string { + if (!/^\d+(\.\d+)?(Gi|Mi|G|M)$/.test(raw.trim())) throw new Error(`invalid memory: ${raw} (try 4Gi or 8Gi)`) + return raw.trim() +} + +// MiB → display without lying: whole/half GiB collapse, anything else stays exact in MiB +// (1536 MiB is "1.5 GiB", 1300 MiB is "1300 MiB" — never "1 GiB"). +export function fmtMib(mib: number): string { + return mib >= 1024 && mib % 512 === 0 ? `${mib / 1024} GiB` : `${mib} MiB` +} + +// The read outcome, as a seam. rawRequest THROWS ApiError on any status >= 400 (api.ts — it only +// differs from request in returning {status,body} below 400, for 202 branching), so the Neon case +// and the friendly wrapping must live in a catch, not in status branching on the return value — +// branches on res.status >= 400 after rawRequest are unreachable. Takes the client as an argument +// so tests drive it with a stub, per this repo's pure-seam convention. +export type DbInstanceRead = { kind: 'ok'; body: any } | { kind: 'no-instance' } + +export async function fetchDbInstance( + api: { rawRequest: (m: string, p: string) => Promise<{ status: number; body: any }> }, + projectId: string, + suffix: string, +): Promise { + try { + const res = await api.rawRequest('GET', `/projects/${projectId}/database/instance${suffix}`) + return { kind: 'ok', body: res.body } + } catch (e) { + // The platform answers a provider-shaped 502 for services with no manageable instance + // (Neon-backed): a soft case, not a failure. Everything else stays an error — an expired + // token must not render as "no ceiling set" — but wrapped so the user sees what failed. + if (e instanceof ApiError && e.status === 502) return { kind: 'no-instance' } + if (e instanceof ApiError) throw new Error(`reading the instance failed (${e.status}): ${e.message}`) + throw e + } +} + +// Show or set a postgres service's resource ceiling (insta-db-backed only). Paid plans — the +// ceiling is the tier lever now that billing follows actual usage. Moves both directions: +// unlike storage it is a cgroup limit, not a provisioned volume. +export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }): Promise { + const api = await ApiClient.load() + const p = await requireProject() + const qs = new URLSearchParams() + const branch = opts.branch ?? p.branch + if (branch) qs.set('branch', branch) + if (opts.group) qs.set('group', opts.group) + const suffix = qs.toString() ? `?${qs}` : '' + + if (!opts.cpu && !opts.memory) { + const read = await fetchDbInstance(api, p.projectId, suffix) + if (read.kind === 'no-instance') { + info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own resources)`) + return + } + if (opts.json) return printJson(read.body) + const cpuMilli = read.body?.cpuMilli + const mib = read.body?.memoryMib + if (typeof cpuMilli === 'number' && typeof mib === 'number') { + const cpu = cpuMilli % 1000 === 0 ? `${cpuMilli / 1000}` : `${cpuMilli}m` + info(`postgres ${opts.group ?? 'default'}: ceiling ${cpu} vCPU / ${fmtMib(mib)}`) + info(' billing is actual usage — the ceiling caps what the database may burn, it is not a price') + } else { + info(`postgres ${opts.group ?? 'default'}: provider reported no ceiling — set one with --cpu/--memory`) + } + return + } + + const body: Record = {} + if (opts.cpu) body.cpu = parseDbCpu(opts.cpu) + if (opts.memory) body.memory = parseDbMemory(opts.memory) + let res + try { + res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${suffix}`, body) + } catch (e) { + if (e instanceof ApiError) throw new Error(`setting the ceiling failed (${e.status}): ${e.message}`) + throw e + } + if (handleApproval(res)) return + if (opts.json) return printJson(res.body) + const cpu = typeof res.body?.cpuMilli === 'number' ? `${res.body.cpuMilli / 1000} vCPU` : (opts.cpu ?? 'unchanged') + const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (opts.memory ?? 'unchanged') + info(`postgres ${opts.group ?? 'default'}: ceiling set to ${cpu} / ${mem}`) +} diff --git a/src/index.ts b/src/index.ts index d724cc0..a9cdad5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -174,11 +174,18 @@ compute.command('suspend [service]').description('Suspend a compute service (RAM .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o))) compute.command('status [service]').description("Show a compute service's desired vs. live state") .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o))) +compute.command('limits [service]').description("Show or set a compute service's resource ceiling (paid plans). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price") + .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') + .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o))) compute.command('always-on [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way') .option('--json').option('--branch ', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o))) // ---- db (postgres service controls) ---- -const db = program.command('db').description('Postgres service controls (always-on / scale-to-zero)') +const db = program.command('db').description('Postgres service controls (limits / always-on / scale-to-zero)') +db.command('limits').description("Show or set a postgres service's resource ceiling (paid plans; insta-db-backed only). Moves both directions") + .option('--cpu ', "vCPU ceiling, e.g. 2 or 2500m").option('--memory ', "memory ceiling, e.g. 4Gi") + .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') + .action(guard((o) => dbCmd.dbLimits(o))) db.command('always-on ').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only') .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') .action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o))) diff --git a/test/limits.test.ts b/test/limits.test.ts new file mode 100644 index 0000000..c63d275 --- /dev/null +++ b/test/limits.test.ts @@ -0,0 +1,130 @@ +// `insta compute limits` / `insta db limits` — the ceiling controls that replace spec picking. +// The parsing seam is what these pin: a user types "1gb", the API takes MB, and getting that +// conversion wrong sets a ceiling an order of magnitude off in either direction. +import { describe, it, expect } from 'vitest' +import { parseMemoryMb } from '../src/commands/compute.js' + +describe('parseMemoryMb', () => { + it('treats a bare number as MB', () => { + expect(parseMemoryMb('512')).toBe(512) + expect(parseMemoryMb('256')).toBe(256) + }) + + it('accepts MB units in the forms people actually type', () => { + for (const raw of ['512mb', '512MB', '512m', '512 MB', ' 512mib ']) { + expect(parseMemoryMb(raw), raw).toBe(512) + } + }) + + it('converts GB to MB', () => { + expect(parseMemoryMb('1gb')).toBe(1024) + expect(parseMemoryMb('2G')).toBe(2048) + expect(parseMemoryMb('1.5gb')).toBe(1536) + expect(parseMemoryMb('8Gi')).toBe(8192) + }) + + it('rejects nonsense rather than guessing', () => { + for (const raw of ['', 'lots', '1tb', '-1gb', '0', 'gb', '1 2gb']) { + expect(() => parseMemoryMb(raw), raw).toThrow() + } + }) + + // The error names an example, because the failure mode without one is a user retrying the same + // invalid string in a different case. + it('suggests a valid form in the error', () => { + expect(() => parseMemoryMb('huge')).toThrow(/try 512mb, 1gb, 2gb/) + }) +}) + +// Review round 2: both jwfing and cubic independently flagged the bare Number() on --cpu (NaN +// serializes to null on the wire) and the unvalidated db strings. These pin the new seams. +import { parseCpu, fmtMb } from '../src/commands/compute.js' +import { fetchDbInstance } from '../src/commands/db.js' +import { ApiError } from '../src/api.js' +import { parseDbCpu, parseDbMemory, fmtMib } from '../src/commands/db.js' + +describe('parseCpu (compute --cpu override)', () => { + it('accepts exactly the provider grid the help text advertises', () => { + for (const n of [1, 2, 4, 6, 8]) expect(parseCpu(String(n))).toBe(n) + }) + it('throws locally on junk AND on off-grid sizes instead of deferring to the server', () => { + for (const raw of ['abc', '', '-2', '1.5', 'two', '3', '100']) { + expect(() => parseCpu(raw), raw).toThrow(/invalid cpu/) + } + }) +}) + +describe('parseDbCpu / parseDbMemory (provider quantity strings)', () => { + it('passes valid k8s quantities through untouched', () => { + expect(parseDbCpu('2')).toBe('2') + expect(parseDbCpu('2500m')).toBe('2500m') + expect(parseDbMemory('4Gi')).toBe('4Gi') + expect(parseDbMemory('2048Mi')).toBe('2048Mi') + }) + it('rejects junk locally with an example', () => { + expect(() => parseDbCpu('huge')).toThrow(/try 2, 4, or 2500m/) + expect(() => parseDbMemory('lots')).toThrow(/try 4Gi or 8Gi/) + expect(() => parseDbMemory('8')).toThrow() // unit required — a bare number is ambiguous here + }) +}) + +describe('fmtMib (display must not claim a ceiling the API did not set)', () => { + it('collapses whole and half GiB', () => { + expect(fmtMib(2048)).toBe('2 GiB') + expect(fmtMib(1536)).toBe('1.5 GiB') // the review example: was shown as "2 GiB" + }) + it('keeps everything else exact in MiB', () => { + expect(fmtMib(1300)).toBe('1300 MiB') + expect(fmtMib(512)).toBe('512 MiB') + }) +}) + +// The round-3 Critical: rawRequest THROWS on >=400, so the Neon soft-path must live in a catch — +// status-branching on its return value was unreachable dead code and a Neon read crashed with a +// raw ApiError. These drive the seam with a stub client, which is exactly the test that would +// have caught it (the 502 branch was never taken by any test). +describe('fetchDbInstance (the read seam)', () => { + const stub = (fn: () => Promise) => ({ rawRequest: fn }) as any + + it('returns the body on success', async () => { + const read = await fetchDbInstance(stub(async () => ({ status: 200, body: { cpuMilli: 4000 } })), 'p1', '') + expect(read).toEqual({ kind: 'ok', body: { cpuMilli: 4000 } }) + }) + + it('maps the provider-shaped 502 (Neon-backed) to the soft no-instance case', async () => { + const read = await fetchDbInstance(stub(async () => { throw new ApiError(502, 'provider request failed') }), 'p1', '') + expect(read).toEqual({ kind: 'no-instance' }) + }) + + it('wraps other API errors instead of rendering them as "no ceiling"', async () => { + await expect(fetchDbInstance(stub(async () => { throw new ApiError(401, 'unauthorized') }), 'p1', '')) + .rejects.toThrow(/reading the instance failed \(401\): unauthorized/) + }) + + it('lets non-API errors (network, bugs) propagate untouched', async () => { + const boom = new TypeError('fetch failed') + await expect(fetchDbInstance(stub(async () => { throw boom }), 'p1', '')).rejects.toBe(boom) + }) +}) + +// fmtMb had no tests while its twin fmtMib did — same must-not-lie property. +describe('fmtMb (compute display)', () => { + it('collapses whole and half GB', () => { + expect(fmtMb(2048)).toBe('2 GB') + expect(fmtMb(1536)).toBe('1.5 GB') + }) + it('keeps everything else exact in MB', () => { + expect(fmtMb(768)).toBe('768 MB') + expect(fmtMb(256)).toBe('256 MB') + }) +}) + +// Case-exactness (round-3 suggestion): k8s quantities are case-sensitive, so local validation +// must reject what the server would. +describe('parseDbMemory case-exactness', () => { + it('rejects lowercase unit variants the backend refuses', () => { + for (const raw of ['4gi', '4GI', '8m', '2048mi']) { + expect(() => parseDbMemory(raw), raw).toThrow(/invalid memory/) + } + }) +})