diff --git a/src/commands/private-network.ts b/src/commands/private-network.ts new file mode 100644 index 0000000..351732d --- /dev/null +++ b/src/commands/private-network.ts @@ -0,0 +1,96 @@ +// `insta compute public-access` and `insta public-access`: show, close or +// re-open a compute-plane service's public access (the platform's PUT .../services/:id/access). +// +// Every insta-compute service has a private address in its project branch: `.insta.internal` +// for compute, `-.insta.internal` for a managed database (the platform's private_host; +// this file only prints what the platform reports, never composes one). Closing public access makes +// the service private-only: its URL answers 403 (a database's public *_URL stops connecting) and +// only services in the same branch reach it. Like `insta postgres public-access off`, closing asks +// first, and a non-interactive caller (agent, CI, --json) must pass --yes. +import { ApiClient, ApiError, requireProject } from '../api.js' +import { handleApproval, info, printJson, refuse } from '../util.js' +import { q, resolveSoleService } from './services.js' +import { alwaysOnArgs, confirmOnTerminal } from './postgres.js' + +export type PrivateNetworkType = 'compute' | 'redis' | 'mysql' | 'mongodb' +type Opts = { json?: boolean; branch?: string; yes?: boolean } +type Deps = { confirm?: (question: string) => Promise; tty?: boolean } +type Row = { id: string; type: string; name: string; public?: boolean; port?: number | null; private_host?: string | null; private_fqdn?: string | null } + +const isDb = (type: string) => type !== 'compute' +const credential = (type: string) => `${type.toUpperCase()}_URL` +const privateCredential = (type: string) => `${type.toUpperCase()}_PRIVATE_URL` + +/** `:` for the private address, or undefined when the platform reports none. */ +export function privateAddress(s: Pick): string | undefined { + return s.private_host ? `${s.private_host}${s.port ? `:${s.port}` : ''}` : undefined +} + +// The show block. Pure, exported for tests. +export function publicAccessLines(s: Row): string[] { + const pub = s.public + const head = `${s.type} ${s.name}: public access ${pub === false ? 'off' : pub === true ? 'on' : 'unknown'}` + const why = pub === false + ? (isDb(s.type) ? ` — ${credential(s.type)} does not connect; apps in this branch use ${privateCredential(s.type)}` : ' — its URL answers 403; only services in this branch reach it') + : pub === true ? (isDb(s.type) ? '' : ' — reachable from the internet at its URL') : '' + const lines = [head + why] + const addr = privateAddress(s) + if (addr) { + lines.push(` private: ${addr}${s.type === 'compute' ? ' (TCP and UDP, from services in this branch)' : ` (from services in this branch; ${privateCredential(s.type)} holds the connection string)`}`) + if (s.private_fqdn) lines.push(` fqdn: ${s.private_fqdn} (for tools that skip the search domain, e.g. nslookup, dig)`) + } else { + lines.push(' private: none yet — the platform reports no private address for this service (not yet back-filled, a worker, or a region without the private network)') + } + return lines +} + +// Map a refusal to a CLI-shaped message. Pure, exported for tests. +export function publicAccessErrorMessage(e: ApiError, ctx: { type: string; service: string; branch?: string; what: string }): string { + const code = e.body?.code + const b = ctx.branch ? ` --branch ${ctx.branch}` : '' + if (code === 'private_network_disabled') return `the private network is not yet available on this deployment — ${ctx.type} ${ctx.service} stays public` + if (code === 'private_network_unavailable_in_region') return `the region of ${ctx.type} ${ctx.service} has no private network yet — it stays public` + if (code === 'no_private_name') return `${ctx.type} ${ctx.service} has no private network name, so closing public access would leave it unreachable — rename it first: \`insta service rename ${ctx.type} ${ctx.service} ${b}\`` + if (code === 'private_name_taken') return `${e.message} — rename one of the services: \`insta service rename ${ctx.type} ${ctx.service} ${b}\`` + return `${ctx.what} failed (${e.status}): ${e.message}` +} + +// What closing will break, said before asking. Pure, exported for tests. +export function closeWarning(s: Row): string { + return isDb(s.type) + ? `closing public access: ${credential(s.type)} (the public address) stops connecting — external clients, CI and local development can no longer reach ${s.type} ${s.name}, and apps in this branch must use ${privateCredential(s.type)} (bind it and redeploy them)` + : `closing public access: ${s.type} ${s.name}'s URL will answer 403 and stop waking it — browsers, webhooks and every caller outside this branch lose it; services in this branch keep reaching it at its private address` +} + +export async function servicePublicAccess(type: PrivateNetworkType, first: string | undefined, second: string | undefined, opts: Opts, deps: Deps = {}): Promise { + const { mode, service } = alwaysOnArgs(first, second) + 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)}`) + const svc = resolveSoleService(services as Row[], type, service) + if (!mode) { + if (opts.json) return printJson({ name: svc.name, type: svc.type, public: svc.public ?? null, private_host: svc.private_host ?? null, private_fqdn: svc.private_fqdn ?? null, port: svc.port ?? null }) + for (const l of publicAccessLines(svc)) info(l) + return + } + if (mode === 'off' && !opts.yes) { + const tty = deps.tty ?? (!!process.stdin.isTTY && !!process.stdout.isTTY) + if (opts.json || !tty) refuse([closeWarning(svc), `${type} ${svc.name}: re-run with --yes to confirm`]) + info(closeWarning(svc)) + const ok = await (deps.confirm ?? confirmOnTerminal)(`Close public access to ${type} ${svc.name}?`) + if (!ok) return info(`${type} ${svc.name}: public access unchanged`) + } + let res + try { + res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/access`, { public: mode === 'on' }) + } catch (e) { + if (e instanceof ApiError) throw new Error(publicAccessErrorMessage(e, { type, service: svc.name, branch: opts.branch, what: mode === 'off' ? 'closing public access' : 'opening public access' })) + throw e + } + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body) + const notice = typeof res.body?.notice === 'string' && res.body.notice ? res.body.notice : undefined + info(`${type} ${svc.name}: public access ${mode === 'off' ? 'CLOSED' : 'open'}`) + if (notice) info(` ${notice}`) +} diff --git a/src/commands/services.ts b/src/commands/services.ts index f96470b..742ada6 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -215,7 +215,7 @@ export function pgBadge(v: unknown): string { // 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; volume_gib?: number | null; volume_mount_path?: string | null; pg_version?: number | null; endpoint?: string | null; endpoint_host?: string | null }): string { +export function serviceListLine(s: { type: string; name: string; status: string; id: string; domain?: string; machine_count?: number; public?: boolean; image?: string; port?: number; volume_gib?: number | null; volume_mount_path?: string | null; pg_version?: number | null; endpoint?: string | null; endpoint_host?: string | null; private_host?: string | null }): string { const extra = s.type === 'compute' ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi at ${s.volume_mount_path ?? '/data'}` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}` : ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}` @@ -225,7 +225,11 @@ export function serviceListLine(s: { type: string; name: string; status: string; : s.type === 'postgres' ? pgBadge(s.pg_version) : '' // A tcp compute service has no HTTP domain; its address is the tcp one. const where = s.type === 'compute' && s.endpoint === 'tcp' ? ` ${tcpAddress(s)}` : s.domain ? ` ${s.domain}` : '' - return `${s.type}/${s.name} [${s.status}]${extra}${where} ${s.id}` + // The private address the platform reports (never composed here), and a closed compute service + // says so beside the URL it no longer serves. + const closed = s.type === 'compute' && s.public === false ? ' public off' : '' + const priv = s.private_host ? ` private ${s.private_host}${s.port ? `:${s.port}` : ''}` : '' + return `${s.type}/${s.name} [${s.status}]${extra}${where}${closed}${priv} ${s.id}` } export async function servicesList(opts: { json?: boolean; branch?: string }): Promise { diff --git a/src/index.ts b/src/index.ts index c17cda1..ab4c2f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { buildLogs } from './commands/build-logs.js' import * as computeCmd from './commands/compute.js' import * as githubCmd from './commands/github.js' import * as pgCmd from './commands/postgres.js' +import * as privateNet from './commands/private-network.js' import * as dbQueryCmd from './commands/db-query.js' import * as managedDb from './commands/managed-db.js' import * as storageCmd from './commands/storage.js' @@ -385,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, always-on, exec, ssh, GitHub source, logs, metrics') +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') compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)') .option('--json').option('--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`') @@ -404,6 +405,10 @@ compute.command('scale [count] [service]').description('Set a compute service sa compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --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") .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o))) +compute.command('public-access [mode] [service]').description("Show, close or re-open a compute service's public access (mode: on|off; no mode = print it and the service's private address). Every compute service is reachable by the other services in its project branch at .insta.internal: (TCP and UDP); off = private-only: its URL answers 403 and no longer wakes it, so only services in the same branch reach it. Immediate, no redeploy. Asks first; --yes when there is no terminal or with --json. insta-compute only (gated: service.setAccess)") + .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((mode, service, o) => privateNet.servicePublicAccess('compute', mode, service, o))) compute.command('always-on [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero (the default for new compute services; refused on a worker, port 0). All plans; billing is actual usage either way') .option('--json').option('--branch ', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o))) compute.command('ssh [service]') @@ -485,7 +490,7 @@ addObservability(pg, 'db', 'postgres') // ---- redis / mysql / mongodb (managed databases) ---- for (const type of ['redis', 'mysql', 'mongodb'] as const) { - const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`) + const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, public access, always-on, logs, metrics`) const query = g.command('query [args...]').description(type === 'redis' ? 'Run a redis command against the service via the console exec API — a pre-tokenized argv, e.g. `GET mykey`' : `Run one quoted ${type} statement against the service via the console exec API`) @@ -503,6 +508,10 @@ for (const type of ['redis', 'mysql', 'mongodb'] as const) { .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.serviceVolume(type, service, o))) + g.command('public-access [mode] [service]').description(`Show, close or re-open a ${type} service's public access (mode: on|off; no mode = print it and the private address). On insta-compute every ${type} is reachable from services in its project branch at -${type}.insta.internal (${type.toUpperCase()}_PRIVATE_URL); off = ${type.toUpperCase()}_URL stops connecting and only apps in the branch reach it, through ${type.toUpperCase()}_PRIVATE_URL. Asks first; --yes when there is no terminal or with --json (gated: service.setAccess)`) + .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((mode, service, o) => privateNet.servicePublicAccess(type, mode, service, o))) g.command('always-on [service]').description(`Set a ${type} service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero. Billing is actual usage either way`) .option('--json').option('--branch ', 'branch (default: current)') .action(guard((mode, service, o) => computeCmd.serviceAlwaysOn(type, mode, service, o))) diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index ce6444a..83b002c 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -110,14 +110,14 @@ describe('group shapes', () => { it('each managed database has the same verbs', () => { for (const type of ['redis', 'mysql', 'mongodb']) { const names = commandNames(run([type, '--help']).stdout) - expect(names, type).toEqual(['query', 'status', 'limits', 'volume', 'always-on', 'metrics', 'logs']) + expect(names, type).toEqual(['query', 'status', 'limits', 'volume', 'public-access', 'always-on', 'metrics', 'logs']) } expect(run(['mongodb', 'query', '--help']).stdout).toContain('--database') expect(run(['redis', 'query', '--help']).stdout).not.toContain('--database') }, 30_000) it('compute has scale, logs, metrics and no domain verbs; domain has attach, check, detach', () => { const compute = commandNames(run(['compute', '--help']).stdout) - expect(compute).toEqual(expect.arrayContaining(['scale', 'logs', 'metrics', 'limits', 'volume', 'always-on', 'exec', 'ssh'])) + expect(compute).toEqual(expect.arrayContaining(['scale', 'logs', 'metrics', 'limits', 'volume', 'public-access', 'always-on', 'exec', 'ssh'])) expect(compute).not.toEqual(expect.arrayContaining(['set-domain'])) const domain = commandNames(run(['domain', '--help']).stdout) expect(domain).toEqual(expect.arrayContaining(['attach', 'check', 'detach', 'records'])) diff --git a/test/private-network.test.ts b/test/private-network.test.ts new file mode 100644 index 0000000..78f8397 --- /dev/null +++ b/test/private-network.test.ts @@ -0,0 +1,134 @@ +// `insta compute public-access` / `insta public-access`, and the private address +// on `services list`. Pins the platform call each mode makes, that closing asks first (a +// non-interactive caller must pass --yes), that the platform's coded refusals read as CLI +// instructions, and that the private address is the platform's own (never composed here). +import { beforeEach, describe, expect, it, vi } from 'vitest' +const fake = vi.hoisted(() => ({ request: vi.fn(), rawRequest: vi.fn(), load: vi.fn() })) +vi.mock('../src/api.js', async (original) => ({ + ...await original(), + ApiClient: { load: fake.load }, + requireProject: async () => ({ projectId: 'p1', branch: 'main' }), +})) +vi.mock('../src/util.js', async (original) => ({ + ...await original(), + info: vi.fn(), + printJson: vi.fn(), + refuse: vi.fn((lines: string[]) => { throw Object.assign(new Error('refused'), { lines }) }), +})) +import { ApiError } from '../src/api.js' +import { info, printJson, refuse } from '../src/util.js' +import { closeWarning, publicAccessErrorMessage, publicAccessLines, servicePublicAccess } from '../src/commands/private-network.js' +import { serviceListLine } from '../src/commands/services.js' + +const API = { id: 's1', type: 'compute', name: 'api', public: true, port: 8080, private_host: 'api.insta.internal', private_fqdn: 'api.0123abcd.insta.internal' } +const CACHE = { id: 's2', type: 'redis', name: 'cache', public: true, port: 6379, private_host: 'cache-redis.insta.internal', private_fqdn: 'cache-redis.0123abcd.insta.internal' } +const printed = () => vi.mocked(info).mock.calls.map((c) => c[0]).join('\n') + +beforeEach(() => { + vi.mocked(info).mockClear(); vi.mocked(printJson).mockClear(); vi.mocked(refuse).mockClear() + fake.request.mockReset(); fake.rawRequest.mockReset(); fake.load.mockReset().mockResolvedValue(fake) + fake.request.mockImplementation(async (_m: string, path: string) => { + if (path.startsWith('/projects/p1/services?')) return { services: [API, CACHE] } + throw new Error(`unexpected ${path}`) + }) + fake.rawRequest.mockResolvedValue({ status: 200, body: { service: { ...API, public: false }, notice: "public access is closed: the service's URL answers 403 and does not wake it. Services in this branch reach it at api.insta.internal:8080." } }) +}) + +describe('show', () => { + it('prints public access and the platform-reported private address', async () => { + await servicePublicAccess('compute', undefined, undefined, {}) + expect(fake.rawRequest).not.toHaveBeenCalled() + expect(printed()).toContain('compute api: public access on') + expect(printed()).toContain('private: api.insta.internal:8080') + expect(printed()).toContain('fqdn: api.0123abcd.insta.internal') + }) + it('a service the platform reports no private address for says so, and invents none', () => { + const lines = publicAccessLines({ id: 's', type: 'compute', name: 'jobs', public: true, port: 0 }) + expect(lines.join('\n')).toContain('private: none yet') + expect(lines.join('\n')).not.toContain('.insta.internal') + }) + it('a closed database names the credential that stopped working and the one to use', () => { + expect(publicAccessLines({ ...CACHE, public: false })[0]).toBe('redis cache: public access off — REDIS_URL does not connect; apps in this branch use REDIS_PRIVATE_URL') + }) +}) + +describe('off', () => { + it('warns, asks, and closes on yes', async () => { + const confirm = vi.fn().mockResolvedValue(true) + await servicePublicAccess('compute', 'off', 'api', {}, { tty: true, confirm }) + expect(confirm).toHaveBeenCalledOnce() + expect(fake.rawRequest).toHaveBeenCalledWith('PUT', '/projects/p1/services/s1/access', { public: false }) + expect(printed()).toContain('answer 403') + expect(printed()).toContain('compute api: public access CLOSED') + expect(printed()).toContain('api.insta.internal:8080') + }) + it('changes nothing when the human declines', async () => { + await servicePublicAccess('compute', 'off', undefined, {}, { tty: true, confirm: async () => false }) + expect(fake.rawRequest).not.toHaveBeenCalled() + expect(printed()).toContain('public access unchanged') + }) + it('refuses without --yes when nobody can answer, and under --json', async () => { + await expect(servicePublicAccess('compute', 'off', undefined, {}, { tty: false })).rejects.toThrow('refused') + await expect(servicePublicAccess('compute', 'off', undefined, { json: true }, { tty: true })).rejects.toThrow('refused') + expect(fake.rawRequest).not.toHaveBeenCalled() + expect(vi.mocked(refuse).mock.calls[0]![0].join('\n')).toContain('--yes') + }) + it('--yes closes without asking', async () => { + const confirm = vi.fn() + await servicePublicAccess('redis', 'off', 'cache', { yes: true }, { tty: false, confirm }) + expect(confirm).not.toHaveBeenCalled() + expect(fake.rawRequest).toHaveBeenCalledWith('PUT', '/projects/p1/services/s2/access', { public: false }) + }) + it('a database warning names the credential it breaks', () => { + expect(closeWarning(CACHE)).toContain('REDIS_URL (the public address) stops connecting') + expect(closeWarning(CACHE)).toContain('REDIS_PRIVATE_URL') + }) +}) + +describe('on', () => { + it('re-opens without asking', async () => { + const confirm = vi.fn() + fake.rawRequest.mockResolvedValue({ status: 200, body: { service: API } }) + await servicePublicAccess('compute', 'on', 'api', {}, { tty: true, confirm }) + expect(confirm).not.toHaveBeenCalled() + expect(fake.rawRequest).toHaveBeenCalledWith('PUT', '/projects/p1/services/s1/access', { public: true }) + expect(printed()).toContain('compute api: public access open') + }) +}) + +describe('refusals read as CLI instructions: one row per code', () => { + const err = (status: number, code: string | undefined, message = 'x') => new ApiError(status, message, { error: message, ...(code ? { code } : {}) }) + it.each<[string, ApiError, RegExp]>([ + ['private_network_disabled', err(409, 'private_network_disabled'), /not yet available on this deployment — compute api stays public/], + ['private_network_unavailable_in_region', err(409, 'private_network_unavailable_in_region'), /region of compute api has no private network yet/], + ['no_private_name', err(400, 'no_private_name'), /no private network name.*insta service rename compute api --branch dev/], + ['private_name_taken', err(409, 'private_name_taken', 'the private network name api.insta.internal is already used'), /already used — rename one of the services/], + ['anything else', err(502, undefined, 'boom'), /closing public access failed \(502\): boom/], + ])('%s', (_code, e, want) => { + expect(publicAccessErrorMessage(e, { type: 'compute', service: 'api', branch: 'dev', what: 'closing public access' })).toMatch(want) + }) + it('the command surfaces the mapped message', async () => { + fake.rawRequest.mockRejectedValue(err(409, 'private_network_disabled')) + await expect(servicePublicAccess('compute', 'off', 'api', { yes: true })).rejects.toThrow(/stays public/) + }) +}) + +describe('services list shows the private address', () => { + const base = { status: 'active', id: 'x' } + it('a compute row: URL, then the private address', () => { + expect(serviceListLine({ ...base, type: 'compute', name: 'api', machine_count: 1, port: 8080, domain: 'api.example.com', private_host: 'api.insta.internal', public: true })) + .toBe('compute/api [active] x1 api.example.com private api.insta.internal:8080 x') + }) + it('a closed compute row says public off', () => { + expect(serviceListLine({ ...base, type: 'compute', name: 'api', machine_count: 1, port: 8080, domain: 'api.example.com', private_host: 'api.insta.internal', public: false })) + .toContain('api.example.com public off private api.insta.internal:8080') + }) + it('a managed database row', () => { + expect(serviceListLine({ ...base, type: 'redis', name: 'cache', port: 6379, private_host: 'cache-redis.insta.internal' })) + .toContain('private cache-redis.insta.internal:6379') + }) + it('no private_host: nothing is printed (and the line is exactly as before)', () => { + expect(serviceListLine({ ...base, type: 'compute', name: 'api', machine_count: 1, port: 8080, domain: 'api.example.com' })) + .toBe('compute/api [active] x1 api.example.com x') + }) +})