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
39 changes: 32 additions & 7 deletions src/commands/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ export function parseCpu(raw: string): number {

// 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 }, type: ManagedType = 'compute'): string[] {
export function volumeLines(name: string, volume: { sizeGib: number; mountPath: string; appliedMountPath?: string | null; pending?: boolean } | null, cap: { volumeGib: number }, type: ManagedType = 'compute'): string[] {
if (!volume) return [
type === 'compute'
? `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size <gi>\` — it mounts at /data on the next deploy)`
Expand All @@ -667,14 +667,18 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath:
: 'grow with --size (grow-only); the volume cannot be deleted — remove the service instead'
return [
`${type} ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
...(volume.pending && volume.appliedMountPath != null ? [` pending: ${volume.appliedMountPath ?? '(not deployed)'} → ${volume.mountPath}; deploy to apply (restarts the service). Application configuration is not updated automatically.`] : []),
` billing is actual data stored — the size is a cap, not a price; ${grow}`,
]
}

// 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 }, type: ManagedType = 'compute'): string {
export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string; appliedMountPath?: string | null; pending?: boolean }; cap: { volumeGib: number }; attached?: boolean; changed?: boolean }, type: ManagedType = 'compute', sizeRequested = true): string {
const pending = body.volume.pending && body.volume.appliedMountPath != null
? `; mount path ${body.volume.appliedMountPath} → ${body.volume.mountPath} pending — deploy or restart to apply. Update application paths and startup commands yourself.` : ''
if (body.changed === false && !body.attached) return `${type} ${name}: volume unchanged: ${body.volume.sizeGib}Gi at ${body.volume.mountPath}${pending}`
if (body.attached) {
// Only a compute service has a deploy step for the mount to wait on; a managed database has
// no deploy, so its disk is simply mounted.
Expand All @@ -683,7 +687,7 @@ export function volumeWriteLine(name: string, body: { volume: { sizeGib: number;
: `mounts at ${body.volume.mountPath}`
return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — ${mounts} (plan max ${body.cap.volumeGib}Gi)`
}
return `${type} ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`
return `${type} ${name}: volume ${sizeRequested ? 'grown to ' : ''}${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)${pending}`
}

// Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume
Expand Down Expand Up @@ -722,8 +726,8 @@ type VolumeOpts = LifeOpts & { size?: string; mountPath?: string; delete?: boole
// 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
// ApiError messages as-is).
export async function serviceVolume(type: ManagedType, serviceName: string | undefined, opts: VolumeOpts): Promise<void> {
if (opts.size !== undefined && !opts.size.trim()) throw new Error('--size must not be empty; specify a whole Gi value or omit --size for a path-only edit')
if (opts.delete && opts.mountPath !== undefined) throw new Error('--delete cannot be combined with --mount-path')
if (opts.mountPath !== undefined && !opts.size) throw new Error('--mount-path requires --size when attaching a volume')
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()
Expand All @@ -741,18 +745,22 @@ export async function serviceVolume(type: ManagedType, serviceName: string | und
return
}

if (!opts.size) {
if (!opts.size && opts.mountPath === undefined) {
const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`)
if (opts.json) return printJson(r)
for (const line of volumeLines(svc.name, r.volume, r.cap, type)) info(line)
return
}

const sizeGib = parseVolumeGib(opts.size)
if (opts.mountPath !== undefined && opts.size === undefined) {
const current = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`)
if (!current.volume) throw new Error(`no volume attached; attach one with insta compute volume ${svc.name} --size <gi> --mount-path ${opts.mountPath}`)
}
const sizeGib = opts.size === undefined ? undefined : parseVolumeGib(opts.size)
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) })
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type))
info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type, opts.size !== undefined))
}
export const computeVolume = (serviceName: string | undefined, opts: VolumeOpts): Promise<void> => serviceVolume('compute', serviceName, opts)

Expand Down Expand Up @@ -2055,3 +2063,20 @@ export function hostPatternFor(host: string, suffixes?: readonly string[]): stri
const parts = host.split('.')
return [parts[0], '*', ...parts.slice(2)].join('.')
}

export async function computeStartCommand(serviceName: string | undefined, opts: Opts & { set?: string; clear?: boolean }): Promise<void> {
if (opts.set !== undefined && opts.clear) throw new Error('use --set or --clear, not both')
const api = await ApiClient.load()
const p = await requireProject()
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`)
const svc = resolveSoleService(services, 'compute', serviceName)
if (opts.set === undefined && !opts.clear) {
if (opts.json) return printJson({ service: svc })
info(`compute ${svc.name}: startup command ${(svc as { start_command?: string | null }).start_command || '(image default)'}`)
return
}
const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/services/${svc.id}`, { startCommand: opts.clear ? '' : opts.set })
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
info('Startup command saved; deploy or restart after staging the volume path. CLI secrets changes deploy immediately; use Console to combine variable, command and path changes.')
}
11 changes: 8 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
.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 (the default for new compute services; all plans; billing is actual usage either way)')
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
.option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data; fixed after attachment)')
.option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
.option('--volume <gi>', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
.option('--json')
.action(guard(async (type, name, o) => {
Expand Down Expand Up @@ -308,8 +308,13 @@ compute.command('watch-paths [service]').description("Show or change which paths
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o)))
compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)))
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /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('--mount-path <path>', 'container mount path for a new volume (requires --size; default /data; fixed after attachment)')
compute.command('start-command [service]').description('Show or stage a compute startup command for the next deployment. Runs through sh -c; --clear restores the image default. Stage the volume path and command before deploying. CLI secrets writes redeploy immediately; use Console to combine variables, path and command in one deployment.')
.option('--set <command>', 'startup command to use on the next deploy')
.option('--clear', 'use the image default command on the next deploy')
.option('--json').option('--branch <branch>', 'branch (default: current)')
.action(guard((service, o) => computeCmd.computeStartCommand(service, o)))
compute.command('volume [service]').description("Show, attach, grow, remount, or delete a compute service's persistent volume. --mount-path alone stages an existing volume path change, pending until deploy or restart; an unchanged normalized path is a no-op readback. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /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('--mount-path <path>', 'configure mount path; existing volume changes apply on the next deploy (restarts the service)')
.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)))
Expand Down
51 changes: 45 additions & 6 deletions test/volume-mount-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ vi.mock('../src/api.js', async (original) => ({
requireProject: async () => ({ projectId: 'p1', branch: 'main' }),
}))
vi.mock('../src/util.js', async (original) => ({ ...await original<typeof import('../src/util.js')>(), info: vi.fn(), printJson: vi.fn() }))
import { computeVolume } from '../src/commands/compute.js'
import { info, printJson } from '../src/util.js'
import { computeVolume, computeStartCommand } from '../src/commands/compute.js'
import { servicesAdd, serviceAddedLine, serviceListLine } from '../src/commands/services.js'
beforeEach(() => {
fake.request.mockReset(); fake.rawRequest.mockReset(); fake.load.mockReset().mockResolvedValue(fake)
Expand Down Expand Up @@ -35,11 +36,10 @@ describe('volume mount path requests', () => {
})

describe('mount path validation and list display', () => {
it.each([undefined, ''])('rejects mount path with missing or empty size (%j) before loading configuration', async (size) => {
await expect(computeVolume('web', { size, mountPath: '/cache' })).rejects.toThrow('--mount-path requires --size')
expect(fake.load).not.toHaveBeenCalled()
expect(fake.request).not.toHaveBeenCalled()
expect(fake.rawRequest).not.toHaveBeenCalled()
it('sends a path-only edit without an implicit resize', async () => {
fake.request.mockResolvedValueOnce({ services: [{ id: 's1', type: 'compute', name: 'web' }] }).mockResolvedValueOnce({ volume: { sizeGib: 1, mountPath: '/data' } })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This commit is titled 'report volume changes accurately', but the added tests still exercise none of the new path-only write rendering: volumeWriteLine branches for pending/appliedMountPath and changed === false/attached (compute.ts 678-690) and the volumeLines pending line (compute.ts 664-665) get no assertion. The modified path-only test checks only the PUT request body against the default rawRequest mock, whose fixed { attached: true, volume: {sizeGib:1, mountPath:'/app/storage'} } shape never produces a pending or no-op outcome, so the branches the PR claims are unverified. Add assertions on volumeWriteLine/read output for pending-path and unchanged cases.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/volume-mount-path.test.ts, line 40:

<comment>This commit is titled 'report volume changes accurately', but the added tests still exercise none of the new path-only write rendering: `volumeWriteLine` branches for `pending`/`appliedMountPath` and `changed === false`/`attached` (compute.ts 678-690) and the `volumeLines` pending line (compute.ts 664-665) get no assertion. The modified path-only test checks only the PUT request body against the default `rawRequest` mock, whose fixed `{ attached: true, volume: {sizeGib:1, mountPath:'/app/storage'} }` shape never produces a pending or no-op outcome, so the branches the PR claims are unverified. Add assertions on `volumeWriteLine`/read output for pending-path and unchanged cases.</comment>

<file context>
@@ -36,6 +37,7 @@ describe('volume mount path requests', () => {
 
 describe('mount path validation and list display', () => {
   it('sends a path-only edit without an implicit resize', async () => {
+    fake.request.mockResolvedValueOnce({ services: [{ id: 's1', type: 'compute', name: 'web' }] }).mockResolvedValueOnce({ volume: { sizeGib: 1, mountPath: '/data' } })
     await computeVolume('web', { mountPath: '/cache' })
     expect(fake.rawRequest).toHaveBeenCalledWith('PUT', '/projects/p1/services/s1/volume', { mountPath: '/cache', sizeGib: undefined })
</file context>

await computeVolume('web', { mountPath: '/cache' })
expect(fake.rawRequest).toHaveBeenCalledWith('PUT', '/projects/p1/services/s1/volume', { mountPath: '/cache', sizeGib: undefined })
})
it.each(['/app/storage', '/data', null, undefined])('shows the recorded compute volume path (%j), defaulting legacy rows to /data', (path) => {
const line = serviceListLine({ type: 'compute', name: 'web', status: 'active', id: 's1', machine_count: 1, volume_gib: 1, volume_mount_path: path })
Expand All @@ -51,3 +51,42 @@ describe('mount path validation and list display', () => {
expect(line).not.toContain('/cache')
})
})

describe('startup command staging', () => {
it('saves a command without deploying and honors branch selection', async () => {
await computeStartCommand('web', { set: 'exec postgres -D /new/pg', branch: 'preview' })
expect(fake.request).toHaveBeenCalledWith('GET', '/projects/p1/services?branch=preview')
expect(fake.rawRequest).toHaveBeenCalledTimes(1)
expect(fake.rawRequest).toHaveBeenCalledWith('PATCH', '/projects/p1/services/s1', { startCommand: 'exec postgres -D /new/pg' })
})
it('clears to the image default', async () => {
await computeStartCommand('web', { clear: true })
expect(fake.rawRequest).toHaveBeenCalledWith('PATCH', '/projects/p1/services/s1', { startCommand: '' })
})
it('reads without mutation and rejects conflicting options before loading credentials', async () => {
await computeStartCommand('web', {})
expect(fake.rawRequest).not.toHaveBeenCalled()
fake.load.mockClear()
await expect(computeStartCommand('web', { set: 'x', clear: true })).rejects.toThrow('not both')
expect(fake.load).not.toHaveBeenCalled()
})
})

it('rejects path-only attachment before issuing a write', async () => {
fake.request.mockResolvedValueOnce({ services: [{ id: 's1', type: 'compute', name: 'web' }] }).mockResolvedValueOnce({ volume: null })
await expect(computeVolume('web', { mountPath: '/cache' })).rejects.toThrow('no volume attached')
expect(fake.rawRequest).not.toHaveBeenCalled()
})
it('rejects an explicitly empty size', async () => {
await expect(computeVolume('web', { size: '', mountPath: '/cache' })).rejects.toThrow('--size must not be empty')
expect(fake.load).not.toHaveBeenCalled()
})

it('reads the saved startup command in text and JSON', async () => {
const service = { id: 's1', type: 'compute', name: 'web', start_command: 'exec app' }
fake.request.mockResolvedValue({ services: [service] })
await computeStartCommand('web', {})
expect(info).toHaveBeenCalledWith('compute web: startup command exec app')
await computeStartCommand('web', { json: true })
expect(printJson).toHaveBeenCalledWith({ service })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The info and printJson mocks created in the vi.mock('../src/util.js', ...) factory are never reset: beforeEach only resets fake.request/fake.rawRequest/fake.load, and vitest.config has no clearMocks. toHaveBeenCalledWith therefore inspects the full accumulated history for the whole file, so these assertions can silently pass stale once any earlier test emits the same arguments. Reset both mocks in beforeEach alongside the fake.* resets.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/volume-mount-path.test.ts, line 91:

<comment>The `info` and `printJson` mocks created in the `vi.mock('../src/util.js', ...)` factory are never reset: `beforeEach` only resets `fake.request`/`fake.rawRequest`/`fake.load`, and vitest.config has no `clearMocks`. `toHaveBeenCalledWith` therefore inspects the full accumulated history for the whole file, so these assertions can silently pass stale once any earlier test emits the same arguments. Reset both mocks in `beforeEach` alongside the `fake.*` resets.</comment>

<file context>
@@ -69,3 +71,22 @@ describe('startup command staging', () => {
+  await computeStartCommand('web', {})
+  expect(info).toHaveBeenCalledWith('compute web: startup command exec app')
+  await computeStartCommand('web', { json: true })
+  expect(printJson).toHaveBeenCalledWith({ service })
+})
</file context>

})
21 changes: 20 additions & 1 deletion test/volume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,26 @@ describe('custom mount paths', () => {
it('rejects paths without an attachment and conflicting deletion flags before accessing config', async () => {
await expect(servicesAdd('compute', 'web', { mountPath: '/app/storage' })).rejects.toThrow(/requires --volume/)
await expect(servicesAdd('postgres', 'db', { mountPath: '/app/storage', volume: '1' })).rejects.toThrow(/compute/)
await expect(computeVolume('web', { mountPath: '/app/storage' })).rejects.toThrow(/requires --size/)
await expect(computeVolume('web', { mountPath: '/app/storage', delete: true })).rejects.toThrow(/cannot be combined/)
})
})

describe('pending volume output', () => {
const cap = { volumeGib: 50 }
it('confirms growth as well as a pending remount', () => {
const line = volumeWriteLine('web', { volume: { sizeGib: 20, mountPath: '/new', appliedMountPath: '/data', pending: true }, cap, changed: true, attached: false })
expect(line).toContain('grown to 20Gi')
expect(line).toContain('/data → /new pending')
})
it('does not call a first attachment a remount', () => {
const volume = { sizeGib: 1, mountPath: '/data', appliedMountPath: null, pending: true }
expect(volumeWriteLine('web', { volume, cap, attached: true })).toContain('1Gi attached')
expect(volumeLines('web', volume, cap).join(' ')).not.toContain('pending:')
expect(volumeWriteLine('web', { volume, cap, changed: false })).toContain('unchanged: 1Gi')
})
it('shows actual path edits and does not claim path-only growth', () => {
const volume = { sizeGib: 1, mountPath: '/new', appliedMountPath: '/data', pending: true }
expect(volumeLines('web', volume, cap).join(' ')).toContain('/data → /new')
expect(volumeWriteLine('web', { volume, cap, changed: true }, 'compute', false)).not.toContain('grown')
})
})
Loading