Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/commands/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

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 null on 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
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/compute.ts, line 104:

<comment>Small positive memory values can become a zero ceiling, and oversized values can become `null` on 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.</comment>

<file context>
@@ -89,3 +89,49 @@ export async function computeAlwaysOn(mode: string, serviceName: string | undefi
+  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)
+}
+
</file context>
Suggested change
return Math.round(mb)
const rounded = Math.round(mb)
if (!Number.isFinite(rounded) || rounded < 1) throw new Error(`invalid memory: ${raw}`)
return rounded

}

// 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<void> {
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<string, unknown> = { 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)}`)
}
96 changes: 95 additions & 1 deletion src/commands/db.ts
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 }
Expand All @@ -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)`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: insta db limits --json emits plain text for Neon-backed/no-instance reads because this branch returns through info before the JSON handling. Machine-readable callers cannot safely parse that response; the no-instance branch could return a JSON sentinel (or preserve the prior {} response) before printing the human-readable message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/db.ts, line 87:

<comment>`insta db limits --json` emits plain text for Neon-backed/no-instance reads because this branch returns through `info` before the JSON handling. Machine-readable callers cannot safely parse that response; the no-instance branch could return a JSON sentinel (or preserve the prior `{}` response) before printing the human-readable message.</comment>

<file context>
@@ -36,23 +82,37 @@ export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }):
-      info(`postgres ${opts.group ?? 'default'}: ceiling ${(r.cpuMilli / 1000).toFixed(r.cpuMilli % 1000 ? 1 : 0)} vCPU / ${Math.round(r.memoryMib / 1024)} GiB`)
+    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
+    }
</file context>
Suggested change
info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own resources)`)
if (opts.json) return printJson({})
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`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Non-integer CPU ceilings are displayed as 1500m vCPU instead of the decimal-vCPU format used after setting them. Format all cpuMilli values as ${cpuMilli / 1000} before the existing vCPU suffix.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/db.ts, line 94:

<comment>Non-integer CPU ceilings are displayed as `1500m vCPU` instead of the decimal-vCPU format used after setting them. Format all `cpuMilli` values as `${cpuMilli / 1000}` before the existing `vCPU` suffix.</comment>

<file context>
@@ -36,23 +82,37 @@ export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }):
+    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')
</file context>
Suggested change
const cpu = cpuMilli % 1000 === 0 ? `${cpuMilli / 1000}` : `${cpuMilli}m`
const cpu = `${cpuMilli / 1000}`

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Explicitly empty --cpu/--memory values bypass the new parsers and silently turn a requested set into a read. Check option presence with !== undefined in both the read predicate and payload construction so empty values are rejected locally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/db.ts, line 104:

<comment>Explicitly empty `--cpu`/`--memory` values bypass the new parsers and silently turn a requested set into a read. Check option presence with `!== undefined` in both the read predicate and payload construction so empty values are rejected locally.</comment>

<file context>
@@ -36,23 +82,37 @@ export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }):
-  if (opts.cpu) body.cpu = opts.cpu
-  if (opts.memory) body.memory = opts.memory
-  const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${suffix}`, body)
+  if (opts.cpu) body.cpu = parseDbCpu(opts.cpu)
+  if (opts.memory) body.memory = parseDbMemory(opts.memory)
+  let res
</file context>

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}`)
}
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,18 @@ compute.command('suspend [service]').description('Suspend a compute service (RAM
.option('--json').option('--branch <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>', '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 <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)))
compute.command('always-on <mode> [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>', '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")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.option('--cpu <n>', "vCPU ceiling, e.g. 2 or 2500m").option('--memory <size>', "memory ceiling, e.g. 4Gi")
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
.action(guard((o) => dbCmd.dbLimits(o)))
db.command('always-on <mode>').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>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
.action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o)))
Expand Down
130 changes: 130 additions & 0 deletions test/limits.test.ts
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/)
}
})
})
Loading