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
96 changes: 96 additions & 0 deletions src/commands/private-network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// `insta compute public-access` and `insta <redis|mysql|mongodb> 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: `<name>.insta.internal`
// for compute, `<name>-<type>.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<boolean>; 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`

/** `<host>:<port>` for the private address, or undefined when the platform reports none. */
export function privateAddress(s: Pick<Row, 'private_host' | 'port'>): 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} <new-name>${b}\``
if (code === 'private_name_taken') return `${e.message} — rename one of the services: \`insta service rename ${ctx.type} ${ctx.service} <new-name>${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<void> {
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}`)
}
8 changes: 6 additions & 2 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` : ''}`
Expand All @@ -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<void> {
Expand Down
13 changes: 11 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>', '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 All @@ -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 <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
.option('--json').option('--branch <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 <name>.insta.internal:<port> (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>', 'branch (default: current)')
.action(guard((mode, service, o) => privateNet.servicePublicAccess('compute', mode, service, o)))
compute.command('always-on <mode> [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>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)))
compute.command('ssh [service]')
Expand Down Expand Up @@ -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 <service> [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`)
Expand All @@ -503,6 +508,10 @@ for (const type of ['redis', 'mysql', 'mongodb'] as const) {
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
.option('--json').option('--branch <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 <name>-${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>', 'branch (default: current)')
.action(guard((mode, service, o) => privateNet.servicePublicAccess(type, mode, service, o)))
g.command('always-on <mode> [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>', 'branch (default: current)')
.action(guard((mode, service, o) => computeCmd.serviceAlwaysOn(type, mode, service, o)))
Expand Down
4 changes: 2 additions & 2 deletions test/help-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']))
Expand Down
Loading
Loading