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
35 changes: 28 additions & 7 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,52 @@ export function resolveComputeServiceId(services: Array<{ id: string; type: stri

// ---- commands ----

export async function servicesAdd(type: string, name: string, opts: { branch?: string; public?: boolean } = {}): Promise<void> {
export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string }

// Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
// mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
// type) stays in servicesAdd, ahead of any network/config access.
export function servicesAddRequestBody(type: string, name: string, branch: string | undefined, opts: ServicesAddOpts): Record<string, unknown> {
return {
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}),
}
}

export async function servicesAdd(type: string, name: string, opts: ServicesAddOpts = {}): Promise<void> {
assertType(type)
if (opts.public && type !== 'storage') throw new Error('--public is only valid for storage services')
if (opts.image && type !== 'compute') throw new Error('--image is only valid for compute services')
if (opts.port && type !== 'compute') throw new Error('--port is only valid for compute services')
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, { type, name, ...(branch ? { branch } : {}), public: !!opts.public })
const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts))
if (handleApproval(res)) return
const svc = res.body.service
const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : ''
info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.domain ? ` — ${svc.domain}` : ''}`)
const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : ''
info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${img}${svc.domain ? ` — ${svc.domain}` : ''}`)
renderNextActions(res.body.nextActions)
}

// Render one `services list` row. Pure, so it's unit-tested without a network mock (mirrors
// billingLines in billing.ts). Compute rows show the running image when the platform reports one.
export function serviceListLine(s: { type: string; name: string; status: string; id: string; domain?: string; machine_count?: number; public?: boolean; image?: string; port?: number }): string {
const extra = s.type === 'compute'
? ` x${s.machine_count}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
: s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : ''
return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`
}

export async function servicesList(opts: { json?: boolean; branch?: string }): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`)
if (opts.json) return printJson(services)
if (!services.length) return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute> <name>\`)`)
for (const s of services) {
const extra = s.type === 'compute' ? ` x${s.machine_count}` : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : ''
info(`${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`)
}
for (const s of services) info(serviceListLine(s))
}

export async function servicesRemove(type: string, name: string, opts: { branch?: string } = {}): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ const svc = program.command('services').alias('svc').description('Manage project
svc.command('add <type> <name>').description('Provision a service on demand (assigns a default domain for postgres/compute)')
.option('--branch <branch>', 'target branch (default: current)')
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
.option('--image <url>', 'compute only: run this container image at creation')
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
.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
61 changes: 60 additions & 1 deletion test/services.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest'
import { assertType, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES } from '../src/commands/services.js'
import {
assertType, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES,
servicesAddRequestBody, servicesAdd, serviceListLine,
} from '../src/commands/services.js'

describe('assertType', () => {
it('accepts valid service types', () => {
Expand Down Expand Up @@ -75,3 +78,59 @@ describe('resolveComputeServiceId', () => {
expect(() => resolveComputeServiceId([{ id: 'a', type: 'postgres', name: 'db' }])).toThrow(/no compute service/)
})
})

describe('servicesAddRequestBody', () => {
it('omits image/port when not passed', () => {
const b = servicesAddRequestBody('compute', 'api', 'main', {})
expect(b).toEqual({ type: 'compute', name: 'api', branch: 'main', public: false })
})
it('sends image and port (as a number) when passed', () => {
const b = servicesAddRequestBody('compute', 'api', 'main', { image: 'ghcr.io/acme/api:latest', port: '3000' })
expect(b).toMatchObject({ image: 'ghcr.io/acme/api:latest', port: 3000 })
expect(b.port).toBe(3000) // Number, not the raw string
})
it('omits branch when undefined', () => {
expect(servicesAddRequestBody('postgres', 'db', undefined, {})).toEqual({ type: 'postgres', name: 'db', public: false })
})
it('carries --public through unchanged', () => {
expect(servicesAddRequestBody('storage', 'bkt', 'main', { public: true })).toMatchObject({ public: true })
})
})

describe('servicesAdd validation (throws before any network/config access)', () => {
it('rejects --image for a non-compute type', async () => {
await expect(servicesAdd('storage', 'bkt', { image: 'ghcr.io/acme/api:latest' })).rejects.toThrow(/--image is only valid for compute services/)
})
it('rejects --port for a non-compute type', async () => {
await expect(servicesAdd('postgres', 'db', { port: '3000' })).rejects.toThrow(/--port is only valid for compute services/)
})
it('rejects --public for a non-storage type', async () => {
await expect(servicesAdd('compute', 'api', { public: true })).rejects.toThrow(/--public is only valid for storage services/)
})
it('rejects an unknown service type before any option checks', async () => {
await expect(servicesAdd('lambda', 'x', {})).rejects.toThrow(/postgres\|storage\|compute/)
})
})

describe('serviceListLine', () => {
it('renders a compute row with the running image when present', () => {
const line = serviceListLine({ type: 'compute', name: 'api', status: 'active', id: 'svc_1', machine_count: 1, image: 'ghcr.io/acme/api:latest', port: 8080 })
expect(line).toBe('compute/api [active] x1 running ghcr.io/acme/api:latest:8080 svc_1')
})
it('renders a compute row without a port suffix when port is absent', () => {
const line = serviceListLine({ type: 'compute', name: 'api', status: 'active', id: 'svc_1', machine_count: 1, image: 'ghcr.io/acme/api:latest' })
expect(line).toBe('compute/api [active] x1 running ghcr.io/acme/api:latest svc_1')
})
it('renders a compute row unchanged when no image is reported', () => {
const line = serviceListLine({ type: 'compute', name: 'api', status: 'active', id: 'svc_1', machine_count: 2 })
expect(line).toBe('compute/api [active] x2 svc_1')
})
it('renders a storage row with access, unaffected by the image change', () => {
const line = serviceListLine({ type: 'storage', name: 'bkt', status: 'active', id: 'svc_2', public: true })
expect(line).toBe('storage/bkt [active] public svc_2')
})
it('renders a postgres row with domain', () => {
const line = serviceListLine({ type: 'postgres', name: 'db', status: 'active', id: 'svc_3', domain: 'db.example.com' })
expect(line).toBe('postgres/db [active] db.example.com svc_3')
})
})
Loading