-
Notifications
You must be signed in to change notification settings - Fork 0
feat: limits commands (insta compute limits / insta db limits) #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a788387
cb08842
d98b52c
10ae254
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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<void> { | |||||||
| 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<DbInstanceRead> { | ||||||||
| 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<void> { | ||||||||
| 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)`) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
|
||||||||
| 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` | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Non-integer CPU ceilings are displayed as Prompt for AI agents
Suggested change
|
||||||||
| 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<string, unknown> = {} | ||||||||
| if (opts.cpu) body.cpu = parseDbCpu(opts.cpu) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Explicitly empty Prompt for AI agents |
||||||||
| 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}`) | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<any>) => ({ 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/) | ||
| } | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Small positive memory values can become a zero ceiling, and oversized values can become
nullon the wire because finiteness and the rounded result are not validated; rejecting non-finite values and values that round below 1 MB would prevent malformed limit updates.Prompt for AI agents