diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 6e3e6f2..c230622 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -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 { +async function lifecycle(verb: LifeVerb, serviceName: string | undefined, opts: LifeOpts): Promise { const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch @@ -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 { const api = await ApiClient.load() diff --git a/src/index.ts b/src/index.ts index daeca31..f423624 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ').description('Attach a custom domain to a branch compute service (gated: deploy)') .option('--branch ').option('--group ').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o))) compute.command('check-domain ').description("Show a custom domain's cert status + required DNS records") @@ -228,6 +228,8 @@ compute.command('stop [service]').description('Take a compute service offline; t .option('--json').option('--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 (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 (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 (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") diff --git a/test/compute-restart.test.ts b/test/compute-restart.test.ts new file mode 100644 index 0000000..0eade8b --- /dev/null +++ b/test/compute-restart.test.ts @@ -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/) + }) +})