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
22 changes: 19 additions & 3 deletions src/commands/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,26 @@ export function renderRemoveDomain(body: any, json?: boolean, row?: ComputeRow):
info(`removed custom domain ${body.hostname} from ${body.service ?? row?.name ?? body.flyApp}${region ? ` (${region})` : ''}`)
}

// ---- lifecycle (start/stop/suspend/status) ----
// ---- lifecycle (start/stop/suspend/restart/status) ----

type LifeOpts = { json?: boolean; branch?: string }
type LifeVerb = 'start' | 'stop' | 'suspend' | 'restart'
export type LifeBody = { service?: { name?: string; desired_state?: string; image?: string }; state?: string }

// The line a lifecycle verb prints. restart gets its own wording: `running` is a PRECONDITION of a
// restart (the platform refuses it in any other desired state), so echoing desired_state back says
// nothing — what the operator needs is which image came back up and whether it is live. Pure,
// exported for tests.
export function lifecycleLine(verb: LifeVerb, fallbackName: string, body: LifeBody): string {
const name = body.service?.name ?? fallbackName
if (verb === 'restart') {
const image = body.service?.image ? ` on ${body.service.image}` : ''
return `restarted compute ${name}${image} — env re-resolved from the current secrets (live: ${body.state})`
}
return `compute ${name}: ${verb} → desired=${body.service?.desired_state} (live: ${body.state})`
}

async function lifecycle(verb: 'start' | 'stop' | 'suspend', serviceName: string | undefined, opts: LifeOpts): Promise<void> {
async function lifecycle(verb: LifeVerb, serviceName: string | undefined, opts: LifeOpts): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
Expand All @@ -344,12 +359,13 @@ async function lifecycle(verb: 'start' | 'stop' | 'suspend', serviceName: string
const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/${verb}`)
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
info(`compute ${res.body.service?.name ?? id}: ${verb} → desired=${res.body.service?.desired_state} (live: ${res.body.state})`)
info(lifecycleLine(verb, id, res.body))
}

export const computeStart = (service: string | undefined, opts: LifeOpts) => lifecycle('start', service, opts)
export const computeStop = (service: string | undefined, opts: LifeOpts) => lifecycle('stop', service, opts)
export const computeSuspend = (service: string | undefined, opts: LifeOpts) => lifecycle('suspend', service, opts)
export const computeRestart = (service: string | undefined, opts: LifeOpts) => lifecycle('restart', service, opts)

export async function computeStatus(serviceName: string | undefined, opts: LifeOpts): Promise<void> {
const api = await ApiClient.load()
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ const {
} = computeCmd.splitExecArgs(process.argv)

// ---- compute (lifecycle control + custom domains) ----
const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains')
const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/restart/status) + custom domains')
compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)))
compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
Expand All @@ -228,6 +228,8 @@ compute.command('stop [service]').description('Take a compute service offline; t
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStop(service, o)))
compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)))
compute.command('restart [service]').description("Restart a compute service by re-running the image it already runs against a freshly resolved env bundle — this is how a changed secret or binding reaches a running machine (env is baked into the machine at deploy time), and how a machine that is up but wedged gets cycled (`start` no-ops on one that is already started). No new image, no new spec. The service must be running: a stopped or suspended one comes back with `insta compute start`. All plans; gated: deploy — it lands configuration the same way a deploy does, so a policy denying deploys denies this too (`start`/`stop` stay ungated, and cycle a wedged machine without one). A service whose app fails to answer on its port coming back up reports that failure, and the machines are rolled back, best-effort, to the config they were serving")
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(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")
Expand Down
37 changes: 37 additions & 0 deletions test/compute-restart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// `insta compute restart` line rendering. The seam exists because a restart's answer is not the
// other lifecycle verbs': desired_state is a precondition it enforces, not news it reports.
import { describe, it, expect } from 'vitest'
import { lifecycleLine } from '../src/commands/compute.js'

describe('lifecycleLine', () => {
it('restart names the image that came back and the live state', () => {
expect(lifecycleLine('restart', 'svc-1', { service: { name: 'api', desired_state: 'running', image: 'nginx:1.27' }, state: 'running' }))
.toBe('restarted compute api on nginx:1.27 — env re-resolved from the current secrets (live: running)')
})

// desired_state is always 'running' on a successful restart (the platform refuses every other
// state), so echoing it would be noise — this pins that it is NOT in the restart line.
it('restart does not echo desired_state', () => {
expect(lifecycleLine('restart', 'svc-1', { service: { name: 'api', desired_state: 'running' }, state: 'running' }))
.not.toMatch(/desired/)
})

it('restart omits the image when the platform did not report one', () => {
expect(lifecycleLine('restart', 'svc-1', { service: { name: 'api' }, state: 'running' }))
.toBe('restarted compute api — env re-resolved from the current secrets (live: running)')
})

it('start/stop/suspend keep the desired-vs-live line', () => {
expect(lifecycleLine('stop', 'svc-1', { service: { name: 'api', desired_state: 'stopped' }, state: 'stopped' }))
.toBe('compute api: stop → desired=stopped (live: stopped)')
expect(lifecycleLine('start', 'svc-1', { service: { name: 'api', desired_state: 'running' }, state: 'running' }))
.toBe('compute api: start → desired=running (live: running)')
})

// An older platform can answer without the service object; the id the CLI resolved is the
// fallback, so the line never loses which service it is talking about.
it('falls back to the resolved service id when the platform omits the service', () => {
expect(lifecycleLine('restart', 'svc-1', { state: 'running' })).toMatch(/^restarted compute svc-1 —/)
expect(lifecycleLine('suspend', 'svc-1', { state: 'stopped' })).toMatch(/^compute svc-1: suspend/)
})
})
Loading