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
52 changes: 44 additions & 8 deletions src/commands/compute.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'
import { resolveComputeServiceId, q, parseVolumeGib } from './services.js'

Expand Down Expand Up @@ -120,7 +120,7 @@ export function parseCpu(raw: string): number {
return n
}

// ---- volume (the persistent /data disk; attach any time, grow-only, never detach) ----
// ---- volume (the persistent /data disk; attach any time, grow-only, deletable; never detach) ----

// Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view;
// only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks.
Expand All @@ -130,7 +130,7 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath:
]
return [
`compute ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only)',
' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only), delete with --delete (destroys the data)',
]
}

Expand All @@ -144,19 +144,55 @@ export function volumeWriteLine(name: string, body: { volume: { sizeGib: number;
return `compute ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`
}

type VolumeOpts = LifeOpts & { size?: string }
// Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume
// path (there is no detach), so the line says what came back with it: the two constraints the
// volume imposed.
export function volumeDeleteLine(name: string): string {
return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back`
}

// Map a DELETE .../volume failure. Pure, exported for tests (r2d2 review rounds 1+2: this is the
// close-call branch worth pinning). An older backend has no DELETE route, and what its 404 looks
// like depends on who answered: the real platform (Fastify, no custom notFound handler) sends its
// default body {"message":"Route DELETE:/… not found","error":"Not Found"} → ApiError message
// "Not Found"; a proxy or bodyless 404 leaves ApiError's own "HTTP 404" fallback. BOTH are the
// generic route-miss shape and mean version skew, not a bug — parroting them would send the user
// hunting the wrong thing. A backend that HAS the route names the real problem in a DOMAIN
// message ("this service has no volume", …), which must flow verbatim, 404 or not.
const GENERIC_404 = /^(HTTP 404|Not Found)$/i
export function volumeDeleteError(e: unknown): unknown {
if (e instanceof ApiError && e.status === 404 && GENERIC_404.test(e.message.trim())) {
return new Error('this backend does not support volume delete yet — update the platform, or delete the service to remove its volume')
}
return e
}

// Show, attach, or grow a compute service's /data volume. No --size: a safe read (size + mount
// path + the plan cap). --size: PUT .../volume — attaches when no volume exists, grows otherwise.
// The paid/cap/machine-count gates all belong to the backend, whose 403/400 messages carry the
// upgrade hints and must reach the user verbatim (the guard prints ApiError messages as-is).
type VolumeOpts = LifeOpts & { size?: string; delete?: boolean }

// Show, attach, grow, or delete a compute service's /data volume. No flag: a safe read (size +
// mount path + the plan cap). --size: PUT .../volume — attaches when no volume exists, grows
// otherwise. --delete: DELETE .../volume — destroys the disk and its data immediately (no detach,
// no undo; billing stops now). The paid/cap/machine-count gates all belong to the backend, whose
// 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
// ApiError messages as-is).
export async function computeVolume(serviceName: string | undefined, opts: VolumeOpts): Promise<void> {
if (opts.delete && opts.size) throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)')
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.delete) {
let res
try { res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}/volume`) }
catch (e) { throw volumeDeleteError(e) }
if (handleApproval(res)) return
if (opts.json) return printJson(res.body)
info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id))

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: The delete success path can crash if the volume DELETE endpoint answers with an empty body (commonly 204 No Content). ApiClient.fetch returns body: null for an empty response, so res.body.service?.name on the non---json path throws TypeError: Cannot read properties of null instead of printing the confirmation that the data is gone. On an irreversible destructive command that message is the one that must never be lost. The ?? serviceName ?? id fallback already intent to degrade gracefully on an absent .service, but it doesn't protect against a null res.body. Suggest using res.body?.service?.name ?? serviceName ?? id so a successful empty-body delete still confirms completion.

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 192:

<comment>The delete success path can crash if the volume DELETE endpoint answers with an empty body (commonly 204 No Content). `ApiClient.fetch` returns `body: null` for an empty response, so `res.body.service?.name` on the non-`--json` path throws `TypeError: Cannot read properties of null` instead of printing the confirmation that the data is gone. On an irreversible destructive command that message is the one that must never be lost. The `?? serviceName ?? id` fallback already intent to degrade gracefully on an absent `.service`, but it doesn't protect against a null `res.body`. Suggest using `res.body?.service?.name ?? serviceName ?? id` so a successful empty-body delete still confirms completion.</comment>

<file context>
@@ -144,19 +144,55 @@ export function volumeWriteLine(name: string, body: { volume: { sizeGib: number;
+    catch (e) { throw volumeDeleteError(e) }
+    if (handleApproval(res)) return
+    if (opts.json) return printJson(res.body)
+    info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id))
+    return
+  }
</file context>
Suggested change
info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id))
info(volumeDeleteLine(res.body?.service?.name ?? serviceName ?? id))

return
}

if (!opts.size) {
const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/volume`)
if (opts.json) return printJson(r)
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ compute.command('limits [service]').description("Show or set a compute service's
.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)))
compute.command('volume [service]').description("Show, attach, or grow a compute service's persistent /data volume. No --size: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price")
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
.option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)))

// ---- db (postgres service controls) ----
Expand Down
49 changes: 48 additions & 1 deletion test/volume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
// deprecated storage* aliases the platform drops next release.
import { describe, it, expect } from 'vitest'
import { parseVolumeGib, servicesAddRequestBody, servicesAdd, serviceListLine } from '../src/commands/services.js'
import { volumeLines, volumeWriteLine } from '../src/commands/compute.js'
import { volumeLines, volumeWriteLine, volumeDeleteLine, volumeDeleteError, computeVolume } from '../src/commands/compute.js'
import { ApiError } from '../src/api.js'
import { dbVolumeLines } from '../src/commands/db.js'

describe('parseVolumeGib', () => {
Expand Down Expand Up @@ -67,6 +68,8 @@ describe('volumeLines (compute read display)', () => {
const lines = volumeLines('api', { sizeGib: 10, mountPath: '/data' }, { volumeGib: 50 })
expect(lines[0]).toBe('compute api: volume 10Gi at /data (plan max 50Gi)')
expect(lines[1]).toMatch(/cap, not a price/)
// The read is where a user learns the way OFF the volume path exists — and what it costs.
expect(lines[1]).toMatch(/--delete \(destroys the data\)/)
})
it('points a volumeless service at the attach verb (this command with --size)', () => {
const lines = volumeLines('api', null, { volumeGib: 50 })
Expand All @@ -92,6 +95,50 @@ describe('volumeWriteLine (compute PUT result display)', () => {
})
})

describe('volumeDeleteLine (compute DELETE result display)', () => {
// The delete line's job is closure: the data is gone (no detach existed, no undo exists), and
// the two constraints the volume imposed — cold stop/start wake and machineCount 1 — left with
// it. No cap/size: there is nothing left to size.
it('says the disk and data are gone and both constraints are back', () => {
const line = volumeDeleteLine('api')
expect(line).toBe('compute api: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back')
})
})

describe('volumeDeleteError (older-backend 404 mapping)', () => {
// The close-call branch: a bare route-404 (no error body → ApiError falls back to the literal
// "HTTP 404") means the BACKEND is old; any 404 that carries a message came from a backend that
// HAS the route and is naming the real problem — verified live against feat/volume-remove
// (dev:fake): a volumeless service answers `{"error":"this service has no volume"}`.
it('maps a bare route-404 to the version-skew hint', () => {
const out = volumeDeleteError(new ApiError(404, 'HTTP 404')) as Error
expect(out.message).toMatch(/does not support volume delete yet/)
})
it('maps the REAL older-platform 404: the Fastify default body parses to "Not Found" (r2d2 round 2)', () => {
// The exact body an older platform (Fastify, no custom notFound handler) sends for a missing
// route, pushed through the same extraction rawRequest applies (`body?.error ?? "HTTP 404"`) —
// the fixture derivation r2d2 asked for, so this test breaks if either side's shape drifts.
const fastifyDefault404 = { message: 'Route DELETE:/projects/p/services/s/volume not found', error: 'Not Found', statusCode: 404 }
const e = new ApiError(404, (fastifyDefault404 as { error?: string }).error ?? 'HTTP 404')
expect((volumeDeleteError(e) as Error).message).toMatch(/does not support volume delete yet/)
})
it('passes a 404 WITH a body message through verbatim — that backend has the route', () => {
const e = new ApiError(404, 'this service has no volume')
expect(volumeDeleteError(e)).toBe(e)
})
it('passes every non-404 through untouched (403 governance, 502 provider, plain errors)', () => {
for (const e of [new ApiError(403, 'approval required'), new ApiError(502, 'provider failed'), new Error('boom')]) {
expect(volumeDeleteError(e)).toBe(e)
}
})
})

describe('computeVolume --delete validation (throws before any network/config access)', () => {
it('rejects --delete combined with --size — one changes the volume, the other destroys it', async () => {
await expect(computeVolume('api', { delete: true, size: '10' })).rejects.toThrow(/--delete cannot be combined with --size/)
})
})

describe('dbVolumeLines (postgres read display)', () => {
it('reads the canonical volumeGib + cap, and shows region when the instance reports one', () => {
const lines = dbVolumeLines('default', { volumeGib: 10, volumeSize: '10Gi', cap: { volumeGib: 50 }, region: 'us-east' })
Expand Down
Loading