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
25 changes: 17 additions & 8 deletions src/commands/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,26 +120,36 @@ export function parseCpu(raw: string): number {
return n
}

// ---- volume (the persistent /data disk; attach is create-time only) ----
// ---- volume (the persistent /data disk; attach any time, grow-only, 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.
export function volumeLines(name: string, volume: { sizeGib: number; mountPath: string } | null, cap: { volumeGib: number }): string[] {
if (!volume) return [
`compute ${name}: no volume attached (attach is create-time only: \`insta services add compute <name> --volume <gi>\`)`,
`compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size <gi>\` — it mounts at /data on the next deploy)`,
]
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)',
]
}

// Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what
// tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live
// disk was already extended); the wire size is authoritative in both cases.
export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string }; cap: { volumeGib: number }; attached?: boolean }): string {
if (body.attached) {
return `compute ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)`
}
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 }

// Show or grow a compute service's /data volume. No --size: a safe read (size + mount path + the
// plan cap). --size: grow via PUT .../volume — paid and grow-only, but both gates 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).
// 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).
export async function computeVolume(serviceName: string | undefined, opts: VolumeOpts): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
Expand All @@ -158,8 +168,7 @@ export async function computeVolume(serviceName: string | undefined, opts: Volum
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib })
if (handleApproval(res)) return
if (opts.json) return printJson(res.body)
const v = res.body.volume
info(`compute ${res.body.service?.name ?? serviceName ?? id}: volume grown to ${v.sizeGib}Gi at ${v.mountPath} (plan max ${res.body.cap.volumeGib}Gi)`)
info(volumeWriteLine(res.body.service?.name ?? serviceName ?? id, res.body))
}

type LimitsOpts = LifeOpts & { cpu?: string; memory?: string }
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
.option('--image <url>', 'compute only: run this container image at creation')
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
.option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (create-time only; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
.action(guard((type, name, o) => services.servicesAdd(type, name, o)))
svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
.action(guard((o) => services.servicesList(o)))
Expand Down Expand Up @@ -182,7 +182,7 @@ 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 or grow a compute service's persistent /data volume. No --size: print size, mount path, and the plan cap (any plan). --size grows it (paid plans; grow-only — a provisioned disk cannot shrink). Attach is create-time only: `insta services add compute <name> --volume <gi>`. Billing is actual data stored — the size is a cap, not a price")
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")
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)))

Expand Down
25 changes: 21 additions & 4 deletions test/volume.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Volumes — the persistent disk attached to a service (postgres has one by default; compute
// opts in at creation). These pin the volume seams: the size parser (the only place a user-typed
// opts in at creation or attaches later via `compute volume --size`). These pin the volume
// seams: the size parser (the only place a user-typed
// Gi becomes a wire integer), the create-body mapping (canonical volumeGib), the free-plan UX rule
// (viewing and attaching-at-default are never pre-blocked client-side — only the backend's 403
// speaks for the paid growth gate), and the db read using the CANONICAL volume* names, not the
// 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 } from '../src/commands/compute.js'
import { volumeLines, volumeWriteLine } from '../src/commands/compute.js'
import { dbVolumeLines } from '../src/commands/db.js'

describe('parseVolumeGib', () => {
Expand Down Expand Up @@ -67,11 +68,27 @@ describe('volumeLines (compute read display)', () => {
expect(lines[0]).toBe('compute api: volume 10Gi at /data (plan max 50Gi)')
expect(lines[1]).toMatch(/cap, not a price/)
})
it('explains the create-time-only attach when no volume exists', () => {
it('points a volumeless service at the attach verb (this command with --size)', () => {
const lines = volumeLines('api', null, { volumeGib: 50 })
expect(lines).toHaveLength(1)
expect(lines[0]).toMatch(/no volume attached/)
expect(lines[0]).toMatch(/insta services add compute <name> --volume <gi>/)
expect(lines[0]).toMatch(/insta compute volume api --size <gi>/)
expect(lines[0]).toMatch(/next deploy/)
})
})

describe('volumeWriteLine (compute PUT result display)', () => {
it('a first attach says so, and that the disk mounts on the next deploy', () => {
const line = volumeWriteLine('api', { volume: { sizeGib: 3, mountPath: '/data' }, cap: { volumeGib: 10 }, attached: true })
expect(line).toBe('compute api: volume 3Gi attached — mounts at /data on the next deploy (plan max 10Gi)')
})
it('a grow reads as a grow — attached false and absent alike (older backends omit it)', () => {
for (const body of [
{ volume: { sizeGib: 5, mountPath: '/data' }, cap: { volumeGib: 10 }, attached: false },
{ volume: { sizeGib: 5, mountPath: '/data' }, cap: { volumeGib: 10 } },
]) {
expect(volumeWriteLine('api', body)).toBe('compute api: volume grown to 5Gi at /data (plan max 10Gi)')
}
})
})

Expand Down
Loading