Skip to content
Draft
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
183 changes: 182 additions & 1 deletion src/commands/compute.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AgentApprovalRequired, ApiClient, ApiError, requireProject } from '../api.js'
import { AgentSessionMissing } from '../agent.js'
import { safeText } from '../config.js'
import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink } from '../util.js'
import * as clack from '@clack/prompts'
import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink, relativeTime, CliCancel } from '../util.js'
import { parseRunLimit } from './cron.js'
import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib, parseCount } from './services.js'

type Opts = { branch?: string; group?: string; json?: boolean }
Expand Down Expand Up @@ -414,6 +416,185 @@ export async function computeStatus(serviceName: string | undefined, opts: LifeO
info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`)
}

// ---- deployments / rollback ----

export type ServiceDeploymentView = {
id: string
status: 'succeeded' | 'failed'
trigger: string
image: string
pinnedImage: string | null
commitSha: string | null
buildId: string | null
buildSource: 'github' | 'archive' | null
port: number | null
startCommand: string | null
actor: { kind: string; userId?: string; email?: string } | null
rollbackOf: string | null
error: string | null
backfilled: boolean
createdAt: string
live: boolean
rollbackEligible: boolean
ineligibleReason?: 'live' | 'failed' | 'same_image'
variablesChangedSince?: number
}
type DeploymentsBody = { deployments: ServiceDeploymentView[]; next?: string; autoDeployPausedByRollback?: boolean }
type RollbackBody = { deployment: ServiceDeploymentView; service?: unknown; autoDeployPaused?: boolean }

const PAUSED_LINE = 'auto-deploy is paused. Turn it back on in the console: Settings → Source'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const INELIGIBLE: Record<string, string> = {
live: 'that deployment is already live',
failed: 'that deployment failed, so there is nothing to roll back to',
same_image: 'that deployment runs the same image as the live one, use restart to redeploy it',
}

const shortImage = (d: ServiceDeploymentView) => (d.pinnedImage ? `…@sha256:${d.pinnedImage.split('@sha256:')[1]?.slice(0, 8) ?? ''}` : d.image)

export function deploymentLines(rows: ServiceDeploymentView[], now: Date): string[] {
if (!rows.length) return ['(no deployments yet)']
const head = ['ID', 'STATUS', 'TRIGGER', 'COMMIT', 'IMAGE', 'WHEN']
const body = rows.map((d) => [
d.id.slice(0, 8), d.live ? 'live' : d.status, d.trigger, d.commitSha?.slice(0, 7) ?? '-', shortImage(d),
relativeTime(d.createdAt, now) + (d.status === 'failed' && d.error ? ` ${d.error}` : ''),
])
const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i]!.length)))
return [head, ...body].map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i]!))).join(' ').trimEnd())
}

export function pickRollbackTarget(rows: ServiceDeploymentView[], to?: string, service = 'this service'): ServiceDeploymentView {
if (to === undefined) {
const liveAt = rows.findIndex((d) => d.live)
const found = rows.slice(liveAt + 1).find((d) => d.rollbackEligible)
if (!found) throw new Error(`no earlier deployment on ${service} is eligible, so there is nothing to roll back to`)
return found
}
const want = to.toLowerCase()
const exact = rows.find((d) => d.id.toLowerCase() === want)
const matches = exact ? [exact] : want.length >= 8 ? rows.filter((d) => d.id.toLowerCase().startsWith(want)) : []
if (matches.length === 0) throw new Error(`no deployment ${to} on ${service}`)
if (matches.length > 1) throw new Error(`deployment id prefix ${to} matches ${matches.length} deployments`)
const t = matches[0]!
if (!t.rollbackEligible) throw new Error(INELIGIBLE[t.ineligibleReason ?? ''] ?? 'that deployment cannot be rolled back to')
return t
}

export function rollbackDisclosure(
target: ServiceDeploymentView,
opts: { port: number | null; autoDeployBranch: string | null; service: string; now: Date },
): string[] {
const commit = target.commitSha ? `commit ${target.commitSha.slice(0, 7)}, ` : ''
const changed = target.variablesChangedSince ? ` (${target.variablesChangedSince} changed since)` : ''
// A backfilled row recorded no port or start command, so the platform keeps the current ones.
return [
`Rolling back ${opts.service} to ${target.id.slice(0, 8)} (${commit}${relativeTime(target.createdAt, opts.now)})`,
target.backfilled ? ' restores image' : ` restores image, ${opts.port != null ? `port ${opts.port}, ` : ''}start command`,
...(target.backfilled ? [' keeps port and start command (not recorded for this deployment)'] : []),
` keeps variables${changed}, scale, domains, volume, databases`,
...(opts.autoDeployBranch ? [` pauses auto-deploy from ${opts.autoDeployBranch}`] : []),
...(target.pinnedImage ? [] : [` warning this deployment used the tag ${target.image}, which may point to a different image now`]),
]
}

// Fastify's default 404 carries the "Route GET:... not found" text in body.message, while ApiError.message is body.error ("Not Found").
const noRoute = (e: unknown) =>
e instanceof ApiError && e.status === 404 && /^Route (GET|POST):/.test(String(e.body?.message ?? e.message))
const NO_ROUTE = 'this platform does not support deployment history yet'

async function deploymentsTarget(opts: { branch?: string }, deps?: DomainDeps) {
const { api, project: p } = await domainDeps(deps)
return { api, p, branch: opts.branch ?? p.branch }
}

export async function computeDeployments(
serviceName: string | undefined, opts: { json?: boolean; branch?: string; limit?: string }, deps?: DomainDeps,
): Promise<void> {
const limit = opts.limit === undefined ? undefined : parseRunLimit(opts.limit)
const { api, p, branch } = await deploymentsTarget(opts, deps)
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`)
const id = resolveComputeServiceId(services, serviceName)
let body: DeploymentsBody
try {
body = await api.request('GET', `/projects/${p.projectId}/services/${id}/deployments${limit === undefined ? '' : `?limit=${limit}`}`)
} catch (e) {
if (noRoute(e)) throw new Error(NO_ROUTE)
throw e
}
if (opts.json) return printJson(body)
for (const line of deploymentLines(body.deployments, new Date())) info(line)
if (body.autoDeployPausedByRollback) info(`\n${PAUSED_LINE}`)
}

// The branch auto-deploy follows, or null when a rollback would pause nothing (no repo, or it is off).
async function autoDeployBranch(api: DomainApi, projectId: string, id: string, branch: string | undefined): Promise<string | null> {
try {
const { source } = await api.request('GET', `/projects/${projectId}/services/${id}/source${q(branch)}`)
return source?.type === 'github' && source.auto_deploy ? source.branch : null
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null
throw e
}
}

export async function computeRollback(
serviceName: string | undefined, opts: { to?: string; yes?: boolean; json?: boolean; branch?: string }, deps?: DomainDeps,
): Promise<void> {
// Same rule as `template rollback`: ask on a terminal, anywhere else (a pipe, --json) say --yes.
const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY
if (!opts.yes && !tty) throw new Error('rollback needs --yes without a terminal to confirm on, so nothing was rolled back')
const { api, p, branch } = await deploymentsTarget(opts, deps)
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`)
const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'compute', serviceName)
const base = `/projects/${p.projectId}/services/${svc.id}`

const firstPage = async (): Promise<DeploymentsBody> => {
try { return await api.request('GET', `${base}/deployments?limit=100`) } catch (e) {
if (noRoute(e)) throw new Error(NO_ROUTE)
throw e
}
}
// A full id goes to the server as is (it validates). The first page only makes the disclosure
// specific, and an id not on it gets the generic one.
let target: ServiceDeploymentView | null = null
let targetId: string
if (opts.to && UUID_RE.test(opts.to)) {
const want = opts.to.toLowerCase()
targetId = opts.to
if (!opts.json) target = (await firstPage()).deployments.find((d) => d.id.toLowerCase() === want) ?? null
} else {
target = pickRollbackTarget((await firstPage()).deployments, opts.to, svc.name)
targetId = target.id
}

if (!opts.json) {
const auto = await autoDeployBranch(api, p.projectId, svc.id, branch)
if (target) for (const line of rollbackDisclosure(target, { port: target.port, autoDeployBranch: auto, service: svc.name, now: new Date() })) info(line)
else {
info(`Rolling back ${svc.name} to ${targetId.slice(0, 8)}`)
info(' restores image, port, start command')
info(' keeps variables, scale, domains, volume, databases')
if (auto) info(` pauses auto-deploy from ${auto}`)
}
if (tty) {
const go = await clack.confirm({ message: 'Continue?' })
if (clack.isCancel(go)) throw new CliCancel()
if (!go) return
}
}

let res
try { res = await api.rawRequest('POST', `${base}/rollback`, { deploymentId: targetId }) } catch (e) {
if (noRoute(e)) throw new Error(NO_ROUTE)
throw e
}
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
const body = res.body as RollbackBody
info(`rolled back ${svc.name} to ${targetId.slice(0, 8)}. ${body.deployment.id.slice(0, 8)} is live.`)
if (body.autoDeployPaused) info(PAUSED_LINE)
}

// ---- exec (one-shot command; no interactive shell/PTY) ----

/** The options `insta compute exec` declares — the one source of truth. index.ts builds the
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ const {
} = computeCmd.splitExecArgs(process.argv)

// ---- compute ----
const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, public access, always-on, exec, ssh, GitHub source, logs, metrics')
const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), deployments and rollback, scale, limits, volume, public access, always-on, exec, ssh, GitHub source, logs, metrics')
compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)))
compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
Expand Down Expand Up @@ -447,6 +447,12 @@ compute.command('start-command [service]').description('Show or stage a compute
.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('deployments [service]').description('List a compute service\'s deployment history, newest first: which one is live, what started each, and why a failed one failed')
.option('--limit <n>', 'rows to show (1..100, default 20)').option('--json').option('--branch <branch>', 'branch (default: current)')
.action(guard((service, o) => computeCmd.computeDeployments(service, o)))
compute.command('rollback [service]').description("Roll a compute service back to an earlier deployment: restores that deployment's image, port and start command (only the image for a backfilled deployment, which recorded neither), keeps variables, scale, domains, volume and databases, and pauses GitHub auto-deploy if it was on, until it is turned back on in the console (Settings → Source). With no --to, the target is the newest successful deployment before the live one that runs a different image. Gated: deploy. Asks on a terminal, anywhere else needs --yes")
.option('--to <id>', 'deployment id (full, or a unique prefix of at least 8 characters)').option('--yes', 'skip the confirmation').option('--json').option('--branch <branch>', 'branch (default: current)')
.action(guard((service, o) => computeCmd.computeRollback(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)')
Expand Down
11 changes: 11 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,14 @@ export function promptPasswordFrom(
export function promptPassword(label = 'Password: '): Promise<string> {
return promptPasswordFrom(process.stdin, process.stdout, label)
}

/** "5 min ago", "2 hours ago", "3 days ago", then the plain date. */
export function relativeTime(iso: string, now: Date): string {
const secs = Math.max(0, Math.floor((now.getTime() - new Date(iso).getTime()) / 1000))
const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'} ago`
if (secs < 60) return 'just now'
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
if (secs < 86400) return plural(Math.floor(secs / 3600), 'hour')
if (secs < 30 * 86400) return plural(Math.floor(secs / 86400), 'day')
return iso.slice(0, 10)
}
94 changes: 94 additions & 0 deletions test/compute-deployments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi, afterEach, afterAll } from 'vitest'
import { ApiError } from '../src/api.js'
import { relativeTime } from '../src/util.js'
import { deploymentLines, computeDeployments, type ServiceDeploymentView } from '../src/commands/compute.js'

const NOW = new Date('2026-10-15T12:00:00Z')
const row = (over: Partial<ServiceDeploymentView> = {}): ServiceDeploymentView => ({
id: '3f2a9c1e-0000-4000-8000-000000000001', status: 'succeeded', trigger: 'github',
image: 'registry/web:abc', pinnedImage: 'registry/web@sha256:9c1e04abdeadbeef', commitSha: 'a1b2c3d4e5f6',
buildId: null, buildSource: 'github', port: 8080, startCommand: null, actor: null, rollbackOf: null, error: null,
backfilled: false, createdAt: '2026-10-15T11:55:00Z', live: false, rollbackEligible: true, ...over,
})

describe('relativeTime', () => {
it('buckets', () => {
const at = (ms: number) => new Date(NOW.getTime() - ms).toISOString()
expect(relativeTime(at(10_000), NOW)).toBe('just now')
expect(relativeTime(at(5 * 60_000), NOW)).toBe('5 min ago')
expect(relativeTime(at(2 * 3600_000), NOW)).toBe('2 hours ago')
expect(relativeTime(at(3 * 86400_000), NOW)).toBe('3 days ago')
expect(relativeTime('2026-08-01T10:00:00Z', NOW)).toBe('2026-08-01')
})
})

describe('deploymentLines', () => {
const rows = [
row({ live: true, rollbackEligible: false, ineligibleReason: 'live' }),
row({ id: '7d41e0b2-0000-4000-8000-000000000002', commitSha: '9f8e7d6aaaa', pinnedImage: 'registry/web@sha256:51aa7c90ffff', createdAt: '2026-10-15T10:00:00Z' }),
row({ id: 'c09b11f4-0000-4000-8000-000000000003', status: 'failed', commitSha: null, pinnedImage: null, image: 'web:v2', error: 'app did not answer on port 8080', createdAt: '2026-10-15T09:00:00Z' }),
]
it('lays out columns, live status, and the failed error', () => {
const lines = deploymentLines(rows, NOW)
expect(lines[0]).toMatch(/^ID\s+STATUS\s+TRIGGER\s+COMMIT\s+IMAGE\s+WHEN$/)
expect(lines[1]).toMatch(/^3f2a9c1e\s+live\s+github\s+a1b2c3d\s+…@sha256:9c1e04ab\s+5 min ago$/)
expect(lines[2]).toMatch(/^7d41e0b2\s+succeeded\s+github\s+9f8e7d6\s+…@sha256:51aa7c90\s+2 hours ago$/)
expect(lines[3]).toMatch(/^c09b11f4\s+failed\s+github\s+-\s+web:v2\s+3 hours ago\s+app did not answer on port 8080$/)
})
it('says so when empty', () => {
expect(deploymentLines([], NOW)).toEqual(['(no deployments yet)'])
})
})

describe('computeDeployments', () => {
const stdout: string[] = []
const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true })
afterEach(() => { stdout.length = 0 })
afterAll(() => outSpy.mockRestore())
const PROJECT = { projectId: 'proj_1', branch: 'main' }
const fake = () => {
const gets: string[] = []
const api: any = {
request: async (_m: string, path: string) => {
gets.push(path)
if (path.includes('/deployments')) return { deployments: [row({ live: true })], autoDeployPausedByRollback: false }
return { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] }
},
}
return { api, gets }
}
it('passes --limit and prints the table', async () => {
const { api, gets } = fake()
await computeDeployments('web', { limit: '5' }, { api, project: PROJECT })
expect(gets).toContain('/projects/proj_1/services/svc_1/deployments?limit=5')
expect(stdout.join('')).toContain('3f2a9c1e')
})
it('--json prints the API body', async () => {
const { api } = fake()
await computeDeployments('web', { json: true }, { api, project: PROJECT })
expect(JSON.parse(stdout.join('')).deployments).toHaveLength(1)
})
it('rejects a bad --limit before any request', async () => {
const { api, gets } = fake()
await expect(computeDeployments('web', { limit: '0' }, { api, project: PROJECT })).rejects.toThrow(/--limit/)
expect(gets).toEqual([])
})
})

describe('computeDeployments on a platform without the route', () => {
const frameworkBody = { message: 'Route GET:/projects/p/services/s/deployments not found', error: 'Not Found', statusCode: 404 }
const mk = (err: ApiError) => ({
request: async (_m: string, path: string) => {
if (path.includes('/deployments')) throw err
return { services: [{ id: 'svc_1', type: 'compute', name: 'web' }] }
},
}) as any
it('maps the framework 404 to the friendly message', async () => {
await expect(computeDeployments('web', {}, { api: mk(new ApiError(404, 'Not Found', frameworkBody)), project: { projectId: 'p' } }))
.rejects.toThrow('this platform does not support deployment history yet')
})
it('leaves a non-framework 404 alone', async () => {
await expect(computeDeployments('web', {}, { api: mk(new ApiError(404, 'no such service', { error: 'no such service' })), project: { projectId: 'p' } }))
.rejects.toThrow('no such service')
})
})
Loading
Loading