diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 1b50be3..36bd52d 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -6,7 +6,7 @@ import { info, die, printJson, handleApproval, renderNextActions, CliExit } from import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner, type BuildRunner } from '../flyctl-build.js' import { packDirectory, windowsModeCaveat, type ArchiveLimits } from '../pack.js' import { deployArchive, uploadArchive, type DeployOutcome, type Uploader } from '../deploy-archive.js' -import { parseEndpoint, parsePort } from './services.js' +import { isV6Endpoint, parseEndpoint, parsePort, type Endpoint } from './services.js' type DeployOpts = { image?: string; branch?: string; group?: string; port?: string; websocket?: boolean; endpoint?: string; replaceSource?: boolean; json?: boolean } @@ -28,10 +28,18 @@ export function deployRequestBody(source: { image: string }, branch: string, opt } } -// Where the deploy answers: the HTTPS URL, or for an endpoint tcp service its tcp address (the -// platform reports no URL for one). Pure, so it's unit-tested. -export function deployedTarget(r: { url?: string; endpointHost?: string | null }, port: string | undefined): string { - if (r.endpointHost) return `tcp://${r.endpointHost}:${port ?? '?'}` +// Where the deploy answers: the HTTPS URL, or for a v6-lane service (tcp, udp, tcp+udp) its +// address, one scheme per protocol (the platform reports no URL for one). The scheme comes from the +// RESULT's `endpoint` (instacloud-platform#681 reports it beside endpointHost, including on a +// redeploy that passed none -- the endpoint the service kept); --endpoint is only the fallback for +// an older platform that names the host but not the endpoint. With neither, the address prints +// without a scheme rather than guessing tcp for what may be a udp service. Pure, so it's unit-tested. +export function deployedTarget(r: { url?: string; endpointHost?: string | null; endpoint?: unknown }, port: string | undefined, flag?: Endpoint): string { + if (r.endpointHost) { + const endpoint = isV6Endpoint(r.endpoint) ? r.endpoint : isV6Endpoint(flag) ? flag : undefined + if (endpoint === undefined) return `${r.endpointHost}:${port ?? '?'} (endpoint unchanged; \`insta service list\` shows its protocol)` + return (endpoint === 'tcp+udp' ? ['tcp', 'udp'] : [endpoint]).map((pr) => `${pr}://${r.endpointHost}:${port ?? '?'}`).join(', ') + } return r.url || '(no URL: a worker)' } @@ -171,12 +179,12 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise if (!dir && !opts.image) die('usage: insta deploy | --image [--branch ] [--group ] [--port ]') // Normalized here, once, so both lanes get the same validated value: the archive lane's body - // (deployArchive) otherwise silently drops anything that isn't exactly 'http'/'tcp' to + // (deployArchive) otherwise silently drops anything that isn't exactly one of the endpoints to // undefined, which let a typo like --endpoint tpc build and deploy using the service's existing // endpoint instead of failing locally. Checked before ApiClient.load()/requireProject(): those // can resolve a project and persist an automatic link for an unlinked directory, so a typo must // die before either runs, not after. - let endpoint: 'http' | 'tcp' | undefined + let endpoint: Endpoint | undefined try { endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint) } catch (e) { @@ -210,8 +218,8 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise // The archive lane's operation already deployed. One output shape whichever lane ran, minus // nextActions, which the operation read does not carry. const d = source.deployed - if (opts.json) return printJson({ image: d.image, url: d.url, ...(d.endpointHost ? { endpointHost: d.endpointHost } : {}), branch: d.branch, group: d.group, machineId: d.machineId }) - info(`deployed ${d.image} -> ${deployedTarget(d, effOpts.port)} (branch ${d.branch}, group ${d.group})`) + if (opts.json) return printJson({ image: d.image, url: d.url, ...(d.endpointHost ? { endpointHost: d.endpointHost } : {}), ...(d.endpoint ? { endpoint: d.endpoint } : {}), branch: d.branch, group: d.group, machineId: d.machineId }) + info(`deployed ${d.image} -> ${deployedTarget(d, effOpts.port, endpoint)} (branch ${d.branch}, group ${d.group})`) return } const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts)) @@ -219,7 +227,7 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise if (handleApproval(res, opts.json)) return const what = source.image if (opts.json) return printJson({ image: source.image, ...res.body }) - info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port)} (branch ${res.body.branch}, group ${res.body.group})`) + info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port, endpoint)} (branch ${res.body.branch}, group ${res.body.group})`) renderNextActions(res.body.nextActions) } diff --git a/src/commands/services.ts b/src/commands/services.ts index 742ada6..dae9eae 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -116,21 +116,38 @@ function defaultDatabasePort(type: string): number { export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; endpoint?: string; region?: string; alwaysOn?: boolean; volume?: string; mountPath?: string; pgVersion?: string; json?: boolean } -// How a compute service is reached: http (the default HTTPS domain) or tcp (raw TCP on its port at -// its own IPv6 address). Checked locally so a typo fails before any network call; which ports tcp -// accepts is the platform's answer, not repeated here. -export function parseEndpoint(raw: string): 'http' | 'tcp' { +// How a compute service is reached: http (the default HTTPS domain), or one of the v6-lane +// endpoints -- tcp, udp, or tcp+udp (raw TCP, UDP, or both on its port at its own IPv6 address). +// Checked locally so a typo fails before any network call; which ports each protocol accepts is +// the platform's answer, not repeated here. +export type Endpoint = 'http' | 'tcp' | 'udp' | 'tcp+udp' +export const ENDPOINTS: readonly Endpoint[] = ['http', 'tcp', 'udp', 'tcp+udp'] +export function parseEndpoint(raw: string): Endpoint { const v = raw.trim().toLowerCase() - if (v !== 'http' && v !== 'tcp') throw new Error(`--endpoint must be http or tcp, got: ${raw}`) - return v + if (!(ENDPOINTS as readonly string[]).includes(v)) throw new Error(`--endpoint must be http, tcp, udp or tcp+udp, got: ${raw}`) + return v as Endpoint } +// tcp, udp and tcp+udp are the same v6 lane with a different protocol set: no HTTP domain, and the +// address is endpoint_host on the service port. +export const isV6Endpoint = (e: unknown): e is Exclude => e === 'tcp' || e === 'udp' || e === 'tcp+udp' + // Where a tcp service is dialed. The host is set once a deploy has confirmed it; before that the // row only knows its port. export function tcpAddress(s: { endpoint_host?: string | null; port?: number | null }): string { return s.endpoint_host ? `tcp://${s.endpoint_host}:${s.port}` : `tcp/${s.port ?? '?'} (address after the first deploy)` } +// Where a v6-lane service (tcp, udp or tcp+udp) is reached, one scheme per protocol it serves: +// tcp://host:port, udp://host:port, or both for tcp+udp. tcp is exactly tcpAddress. Before a +// deploy has confirmed the host, the row only knows the protocol and port. +export function v6Address(s: { endpoint?: string | null; endpoint_host?: string | null; port?: number | null }): string { + if (s.endpoint === 'tcp') return tcpAddress(s) + const protos = s.endpoint === 'tcp+udp' ? ['tcp', 'udp'] : ['udp'] + if (!s.endpoint_host) return `${s.endpoint === 'tcp+udp' ? 'tcp+udp' : 'udp'}/${s.port ?? '?'} (address after the first deploy)` + return protos.map((pr) => `${pr}://${s.endpoint_host}:${s.port}`).join(', ') +} + // 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. @@ -202,7 +219,7 @@ export function serviceAddedLine(type: string, name: string, branch: string | un const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '' // The major belongs next to the connect hint: it decides which psql/pg_dump to reach for. const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '' - const where = svc.endpoint === 'tcp' ? ` — ${tcpAddress(svc)}` : svc.domain ? ` — ${svc.domain}` : '' + const where = isV6Endpoint(svc.endpoint) ? ` — ${v6Address(svc)}` : svc.domain ? ` — ${svc.domain}` : '' return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}` } @@ -223,8 +240,8 @@ export function serviceListLine(s: { type: string; name: string; status: string; // Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client // dumps statements an older server cannot restore). Older platforms send no pg_version. : 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}` : '' + // A tcp/udp/tcp+udp compute service has no HTTP domain; its address is the v6-lane one. + const where = s.type === 'compute' && isV6Endpoint(s.endpoint) ? ` ${v6Address(s)}` : s.domain ? ` ${s.domain}` : '' // 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' : '' diff --git a/src/deploy-archive.ts b/src/deploy-archive.ts index 359d627..3e3a253 100644 --- a/src/deploy-archive.ts +++ b/src/deploy-archive.ts @@ -2,6 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises' import type { ApiClient } from './api.js' import { handleApproval } from './util.js' import type { PackResult } from './pack.js' +import { ENDPOINTS, isV6Endpoint, type Endpoint } from './commands/services.js' // The gateway does NOT fall back to nixpacks when it finds no Dockerfile, it fails. The platform // never sees the tree, so the side that packed it chooses. @@ -89,7 +90,7 @@ const isAbort = (e: unknown): boolean => e instanceof Error && (e.name === 'Time // What the archive lane hands back: the deploy already happened. Same fields `/deploy` answers // with for an image body, so the command prints one shape whichever lane ran. -export type DeployOutcome = { image: string; url: string; endpointHost?: string; branch: string; group: string; machineId?: string } +export type DeployOutcome = { image: string; url: string; endpointHost?: string; endpoint?: Exclude; branch: string; group: string; machineId?: string } export type ArchiveDeployResult = DeployOutcome | { failed: string } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -119,7 +120,7 @@ export async function deployArchive( archive: ref, port: opts.port ? Number(opts.port) : undefined, websocket: typeof opts.websocket === 'boolean' ? opts.websocket : undefined, - endpoint: opts.endpoint === 'http' || opts.endpoint === 'tcp' ? opts.endpoint : undefined, + endpoint: (ENDPOINTS as readonly string[]).includes(opts.endpoint ?? '') ? opts.endpoint : undefined, replaceSource: opts.replaceSource === true ? true : undefined, }) // An approval is a 202 too, told apart by its status word; anything else here is our operation. @@ -164,8 +165,11 @@ export async function deployArchive( if (state === 'live') { const image = res.body?.imageRef const url = res.body?.url - // An endpoint tcp service has no URL: its answer is the tcp host instead. A worker has neither. + // A tcp/udp/tcp+udp (v6-lane) service has no URL: its answer is the v6 host instead. A worker has neither. const endpointHost = typeof res.body?.endpointHost === 'string' && res.body.endpointHost ? res.body.endpointHost as string : undefined + // The endpoint the service is on (instacloud-platform#681), so the address gets its scheme; + // an older platform omits it, and a value this CLI does not know is treated as absent. + const endpoint = endpointHost && isV6Endpoint(res.body?.endpoint) ? res.body.endpoint as Exclude : undefined if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) { throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`') } @@ -175,7 +179,7 @@ export async function deployArchive( return v } return { - image, url, ...(endpointHost ? { endpointHost } : {}), + image, url, ...(endpointHost ? { endpointHost } : {}), ...(endpoint ? { endpoint } : {}), branch: optionalString('branch', res.body.branch) ?? branch, group: optionalString('group', res.body.group) ?? opts.group ?? '', machineId: optionalString('machineId', res.body.machineId), diff --git a/src/index.ts b/src/index.ts index ab4c2f3..b602aeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,7 +156,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (com .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)') .option('--image ', 'compute only: run this container image at creation') .option('--port ', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)') - .option('--endpoint ', 'compute only: how the service is reached — http (default: HTTPS at its domain) or tcp (raw TCP on --port at its own public IPv6 address; IPv6 clients only, no platform TLS, no custom domain). tcp needs --port and refuses commonly scanned, web (80/443) and platform-reserved ports') + .option('--endpoint ', 'compute only: how the service is reached — http (default: HTTPS at its domain), or tcp / udp / tcp+udp (raw TCP, UDP or both on --port at its own public IPv6 address; IPv6 clients only, no platform TLS, no custom domain). tcp needs --port and refuses commonly scanned, web (80/443) and platform-reserved ports; udp refuses amplification-prone (DNS, NTP, SNMP, memcached, …) and platform-reserved ports; tcp+udp refuses both lists. A udp datagram wakes a suspended service and what the wake held is re-sent until the service first replies (an app may see it twice); a receive-only udp service (never replies) needs --always-on to stay awake') .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way). A worker (--port 0) is always-on regardless') .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request (the default for new compute services; refused with --port 0, a worker cannot scale to zero)') .option('--mount-path ', 'compute only: container mount path for a new volume (requires --volume; default /data)') @@ -559,7 +559,7 @@ buildCmd.command('logs ').description('Read source-build output for a program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group') .option('--image ', 'prebuilt container image to deploy (instead of a source dir)').option('--branch ').option('--group ').option('--port

', 'port the app listens on (0 for a worker without a routed port)') .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)') - .option('--endpoint ', 'switch how the service is reached from this deploy on: tcp (raw TCP on --port at its own public IPv6 address; IPv6 clients only) or http (back to its HTTPS domain). Omitted: keep the current endpoint') + .option('--endpoint ', 'switch how the service is reached from this deploy on: tcp / udp / tcp+udp (raw TCP, UDP or both on --port at its own public IPv6 address; IPv6 clients only) or http (back to its HTTPS domain). Omitted: keep the current endpoint') .option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused') .option('--json', 'print the deploy result as JSON (build progress goes to stderr)') .action(guard((dir, o) => deploy(dir, o))) diff --git a/test/endpoint-tcp.test.ts b/test/endpoint-tcp.test.ts index 8cab17d..da798c0 100644 --- a/test/endpoint-tcp.test.ts +++ b/test/endpoint-tcp.test.ts @@ -19,8 +19,8 @@ describe('parseEndpoint', () => { it.each([['http', 'http'], ['tcp', 'tcp'], [' TCP ', 'tcp']])('%j is %s', (raw, want) => { expect(parseEndpoint(raw)).toBe(want) }) - it.each(['udp', 'v6', '', 'https'])('%j is refused', (raw) => { - expect(() => parseEndpoint(raw)).toThrow(/--endpoint must be http or tcp/) + it.each(['sctp', 'v6', '', 'https'])('%j is refused', (raw) => { + expect(() => parseEndpoint(raw)).toThrow(/--endpoint must be http, tcp, udp or tcp\+udp/) }) }) @@ -34,7 +34,7 @@ describe('service add --endpoint', () => { // fail differently. it.each([ ['on a managed database', 'redis', { endpoint: 'tcp' }, '--endpoint is only valid for compute services'], - ['with a typo', 'compute', { endpoint: 'tpc' }, '--endpoint must be http or tcp, got: tpc'], + ['with a typo', 'compute', { endpoint: 'tpc' }, '--endpoint must be http, tcp, udp or tcp+udp, got: tpc'], ])('%s fails locally', async (_label, type, opts, error) => { await expect(servicesAdd(type, 'x', opts)).rejects.toThrow(error) }) @@ -66,15 +66,15 @@ describe('deploy --endpoint', () => { it('rides the deploy body only when given', () => { expect(deployRequestBody({ image: 'img' }, 'main', { port: '7000', endpoint: 'tcp' })).toMatchObject({ endpoint: 'tcp', port: 7000 }) expect(deployRequestBody({ image: 'img' }, 'main', { port: '7000' }).endpoint).toBeUndefined() - expect(() => deployRequestBody({ image: 'img' }, 'main', { endpoint: 'udp' })).toThrow(/--endpoint must be http or tcp/) + expect(() => deployRequestBody({ image: 'img' }, 'main', { endpoint: 'sctp' })).toThrow(/--endpoint must be http, tcp, udp or tcp\+udp/) }) - it.each<[string, { url?: string; endpointHost?: string | null }, string | undefined, string]>([ - ['a tcp deploy', { url: '', endpointHost: HOST }, '7000', `tcp://${HOST}:7000`], - ['an http deploy', { url: 'https://app.example' }, '7000', 'https://app.example'], - ['a worker', { url: '' }, '0', '(no URL: a worker)'], - ])('reports %s', (_label, r, port, want) => { - expect(deployedTarget(r, port)).toBe(want) + it.each<[string, { url?: string; endpointHost?: string | null }, string | undefined, 'tcp' | undefined, string]>([ + ['a tcp deploy', { url: '', endpointHost: HOST }, '7000', 'tcp', `tcp://${HOST}:7000`], + ['an http deploy', { url: 'https://app.example' }, '7000', undefined, 'https://app.example'], + ['a worker', { url: '' }, '0', undefined, '(no URL: a worker)'], + ])('reports %s', (_label, r, port, endpoint, want) => { + expect(deployedTarget(r, port, endpoint)).toBe(want) }) }) diff --git a/test/endpoint-udp.test.ts b/test/endpoint-udp.test.ts new file mode 100644 index 0000000..827185d --- /dev/null +++ b/test/endpoint-udp.test.ts @@ -0,0 +1,247 @@ +// --endpoint udp | tcp+udp: a compute service reached as raw UDP (or TCP and UDP on the one port) at +// its own IPv6 address — the same v6 lane as tcp with a different protocol set. As for tcp, the +// platform owns which ports are allowed (it refuses the udp amplification and platform lists); the +// CLI only spells the flag, sends it, and shows where such a service answers, one scheme per protocol. +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it, expect, afterEach, vi } from 'vitest' +import { parseEndpoint, servicesAdd, servicesAddRequestBody, servicesList, serviceListLine, serviceAddedLine, tcpAddress, v6Address } from '../src/commands/services.js' +import { deployRequestBody, deployedTarget, deploy } from '../src/commands/deploy.js' +import { deployArchive } from '../src/deploy-archive.js' +import * as apiModule from '../src/api.js' +import { ApiClient } from '../src/api.js' + +afterEach(() => { vi.restoreAllMocks(); process.exitCode = undefined }) + +const HOST = 'prod-main-dns-aa6a3.ip.us-east-1.compute.instacloud.tech' + +describe('parseEndpoint: udp and tcp+udp', () => { + it.each([['udp', 'udp'], ['tcp+udp', 'tcp+udp'], [' UDP ', 'udp'], ['TCP+UDP', 'tcp+udp']])('%j is %s', (raw, want) => { + expect(parseEndpoint(raw)).toBe(want) + }) + // The platform spells the pair one way only; a near miss is a typo, refused locally. + it.each(['udp+tcp', 'tcp+', 'tcp,udp', 'tcp udp', 'quic'])('%j is refused', (raw) => { + expect(() => parseEndpoint(raw)).toThrow(`--endpoint must be http, tcp, udp or tcp+udp, got: ${raw}`) + }) +}) + +describe('service add --endpoint udp | tcp+udp', () => { + it.each([['udp'], ['tcp+udp']])('sends endpoint %s', (endpoint) => { + expect(servicesAddRequestBody('compute', 'dns', 'main', { port: '5000', endpoint })).toMatchObject({ port: 5000, endpoint }) + }) + + // No port refusal here: which ports udp accepts is the platform's answer (as for tcp), so a + // udp-denied port like 53 is sent as given and refused server-side. + it('does not mirror the platform port list', () => { + expect(servicesAddRequestBody('compute', 'dns', 'main', { port: '53', endpoint: 'udp' })).toMatchObject({ port: 53, endpoint: 'udp' }) + }) + + it.each([ + ['udp on a managed database', 'redis', { endpoint: 'udp' }, '--endpoint is only valid for compute services'], + ['tcp+udp on storage', 'storage', { endpoint: 'tcp+udp' }, '--endpoint is only valid for compute services'], + ['a reversed pair', 'compute', { endpoint: 'udp+tcp' }, '--endpoint must be http, tcp, udp or tcp+udp, got: udp+tcp'], + ])('%s fails locally', async (_label, type, opts, error) => { + await expect(servicesAdd(type, 'x', opts)).rejects.toThrow(error) + }) + + it('the added line shows a udp service as udp://', () => { + const line = serviceAddedLine('compute', 'dns', 'main', { id: 's1', type: 'compute', image: 'd', port: 5000, endpoint: 'udp', endpoint_host: HOST, domain: 'ignored.example' }) + expect(line).toBe(`added compute service dns on main (s1) running d:5000 — udp://${HOST}:5000`) + }) + it('the added line shows a tcp+udp service under both schemes', () => { + const line = serviceAddedLine('compute', 'dns', 'main', { id: 's1', type: 'compute', image: 'd', port: 5000, endpoint: 'tcp+udp', endpoint_host: HOST }) + expect(line).toBe(`added compute service dns on main (s1) running d:5000 — tcp://${HOST}:5000, udp://${HOST}:5000`) + }) + it('the added line before a deploy has confirmed the host', () => { + expect(serviceAddedLine('compute', 'dns', 'main', { id: 's1', type: 'compute', port: 5000, endpoint: 'udp' })).toBe('added compute service dns on main (s1) — udp/5000 (address after the first deploy)') + }) +}) + +describe('service list: udp and tcp+udp rows', () => { + const base = { type: 'compute', name: 'dns', status: 'active', id: 's1', machine_count: 1, image: 'd', port: 5000 } + it.each<[string, Record, string]>([ + ['udp', { endpoint: 'udp', endpoint_host: HOST }, `compute/dns [active] x1 running d:5000 udp://${HOST}:5000 s1`], + ['tcp+udp', { endpoint: 'tcp+udp', endpoint_host: HOST }, `compute/dns [active] x1 running d:5000 tcp://${HOST}:5000, udp://${HOST}:5000 s1`], + ['udp, not yet deployed', { endpoint: 'udp', endpoint_host: null }, 'compute/dns [active] x1 running d:5000 udp/5000 (address after the first deploy) s1'], + ['tcp+udp, not yet deployed', { endpoint: 'tcp+udp', endpoint_host: null }, 'compute/dns [active] x1 running d:5000 tcp+udp/5000 (address after the first deploy) s1'], + // A v6 service has no HTTP domain; a stray one on the row must not be shown as its address. + ['udp with a stray domain', { endpoint: 'udp', endpoint_host: HOST, domain: 'dns.example.com' }, `compute/dns [active] x1 running d:5000 udp://${HOST}:5000 s1`], + // tcp is exactly what it was before udp existed. + ['tcp (unchanged)', { endpoint: 'tcp', endpoint_host: HOST }, `compute/dns [active] x1 running d:5000 tcp://${HOST}:5000 s1`], + ['http (unchanged)', { endpoint: 'http', domain: 'dns.example.com' }, 'compute/dns [active] x1 running d:5000 dns.example.com s1'], + ])('%s', (_label, extra, want) => { + expect(serviceListLine({ ...base, ...extra })).toBe(want) + }) + + it('v6Address for tcp is tcpAddress, with and without a host', () => { + expect(v6Address({ endpoint: 'tcp', endpoint_host: HOST, port: 7000 })).toBe(tcpAddress({ endpoint_host: HOST, port: 7000 })) + expect(v6Address({ endpoint: 'tcp', endpoint_host: null, port: 7000 })).toBe(tcpAddress({ endpoint_host: null, port: 7000 })) + }) + + it('--json passes endpoint and endpoint_host through untouched', async () => { + const rows = [{ ...base, endpoint: 'tcp+udp', endpoint_host: HOST }] + vi.spyOn(ApiClient, 'load').mockResolvedValue({ request: async () => ({ services: rows }) } as unknown as ApiClient) + vi.spyOn(apiModule, 'requireProject').mockResolvedValue({ projectId: 'p1', branch: 'main' } as Awaited>) + const out: string[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((s) => { out.push(String(s)); return true }) + await servicesList({ json: true }) + expect(JSON.parse(out.join(''))).toEqual(rows) + }) +}) + +describe('deploy --endpoint udp | tcp+udp', () => { + it.each([['udp'], ['tcp+udp']])('rides the deploy body as %s', (endpoint) => { + expect(deployRequestBody({ image: 'img' }, 'main', { port: '5000', endpoint })).toMatchObject({ endpoint, port: 5000 }) + }) + + // An older platform: the result names the host but not the endpoint, so --endpoint is the fallback. + it.each<[string, { url?: string; endpointHost?: string | null }, string | undefined, 'tcp' | 'udp' | 'tcp+udp' | undefined, string]>([ + ['a udp deploy', { url: '', endpointHost: HOST }, '5000', 'udp', `udp://${HOST}:5000`], + ['a tcp+udp deploy', { url: '', endpointHost: HOST }, '5000', 'tcp+udp', `tcp://${HOST}:5000, udp://${HOST}:5000`], + ['a udp deploy with no --port', { url: '', endpointHost: HOST }, undefined, 'udp', `udp://${HOST}:?`], + // The deploy result names the host, not the protocol: without --endpoint the service kept + // whatever v6 endpoint it had, so no scheme is guessed (tcp:// would lie about a udp service). + ['a v6 redeploy without --endpoint', { url: '', endpointHost: HOST }, '5000', undefined, `${HOST}:5000 (endpoint unchanged; \`insta service list\` shows its protocol)`], + ])('reports %s', (_label, r, port, endpoint, want) => { + expect(deployedTarget(r, port, endpoint)).toBe(want) + }) +}) + +// instacloud-platform#681: the result carries `endpoint` beside `endpointHost`, including on a +// redeploy that passed none, and it wins over the flag. +describe('deployedTarget reads the endpoint from the result', () => { + type Row = [string, { url?: string; endpointHost?: string | null; endpoint?: unknown }, 'http' | 'tcp' | 'udp' | 'tcp+udp' | undefined, string] + it.each([ + ['result tcp, no flag (byte-for-byte the tcp line)', { url: '', endpointHost: HOST, endpoint: 'tcp' }, undefined, `tcp://${HOST}:5000`], + ['result udp, no flag', { url: '', endpointHost: HOST, endpoint: 'udp' }, undefined, `udp://${HOST}:5000`], + ['result tcp+udp, no flag', { url: '', endpointHost: HOST, endpoint: 'tcp+udp' }, undefined, `tcp://${HOST}:5000, udp://${HOST}:5000`], + ['flag tcp, result udp: the result wins', { url: '', endpointHost: HOST, endpoint: 'udp' }, 'tcp', `udp://${HOST}:5000`], + ['flag udp, result tcp+udp: the result wins', { url: '', endpointHost: HOST, endpoint: 'tcp+udp' }, 'udp', `tcp://${HOST}:5000, udp://${HOST}:5000`], + ['host without endpoint, flag udp (older platform): the flag', { url: '', endpointHost: HOST }, 'udp', `udp://${HOST}:5000`], + ['host without endpoint, no flag (older platform): no scheme', { url: '', endpointHost: HOST }, undefined, `${HOST}:5000 (endpoint unchanged; \`insta service list\` shows its protocol)`], + ['a result endpoint this CLI does not know is treated as absent', { url: '', endpointHost: HOST, endpoint: 'sctp' }, undefined, `${HOST}:5000 (endpoint unchanged; \`insta service list\` shows its protocol)`], + ['http: the URL, unchanged', { url: 'https://app.example' }, undefined, 'https://app.example'], + ['http with --endpoint http: the URL, unchanged', { url: 'https://app.example' }, 'http', 'https://app.example'], + ])('%s', (_label, r, flag, want) => { + expect(deployedTarget(r, '5000', flag)).toBe(want) + }) +}) + +describe('archive deploy of a udp / tcp+udp service', () => { + const ref = { archiveSha256: 'a'.repeat(64), build: { type: 'dockerfile' as const } } + const api = (live: Record) => { + const calls: Array<{ method: string; path: string; body?: any }> = [] + const script = [{ status: 202, body: { operationId: 'op_1' } }, { status: 200, body: { state: 'live', imageRef: 'img@sha256:aa', branch: 'main', group: 'dns', ...live } }] + let i = 0 + return { calls, api: { rawRequest: async (method: string, path: string, body?: unknown) => { calls.push({ method, path, body }); return script[Math.min(i++, 1)]! } } } + } + const noWait = async () => undefined + + it.each([['udp'], ['tcp+udp']])('sends endpoint %s and accepts an empty URL when the platform names the host', async (endpoint) => { + const { api: a, calls } = api({ url: '', endpointHost: HOST }) + await expect(deployArchive(a, 'p1', ref, 'main', { port: '5000', endpoint }, Date.now, noWait)).resolves.toMatchObject({ url: '', endpointHost: HOST }) + expect(calls[0]!.body).toMatchObject({ endpoint, port: 5000 }) + }) + + it.each<[string, unknown, string | undefined]>([ + ['carries the read\'s endpoint', 'udp', 'udp'], + ['an older platform omits it', undefined, undefined], + ['an unknown value is dropped', 'sctp', undefined], + ])('%s', async (_label, endpoint, want) => { + const { api: a } = api({ url: '', endpointHost: HOST, ...(endpoint !== undefined ? { endpoint } : {}) }) + const out = await deployArchive(a, 'p1', ref, 'main', { port: '5000' }, Date.now, noWait) + expect((out as { endpoint?: string }).endpoint).toBe(want) + }) +}) + +// End to end through deploy(): the line a person reads and the JSON an agent reads, for each lane. +describe('deploy() output for a udp service', () => { + function srcDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'insta-endpoint-udp-')) + writeFileSync(join(dir, 'app.js'), 'console.log(1)\n') + return dir + } + + function fakeApi(resultEndpoint: string | undefined = undefined) { + const ep = resultEndpoint === undefined ? {} : { endpoint: resultEndpoint } + const calls: Array<{ method: string; path: string; body?: unknown }> = [] + const rawRequest = vi.fn(async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }) + if (path.includes('/source-build')) return { status: 200, body: { lane: 'archive', limits: { maxArchiveBytes: 1e6, maxExtractedBytes: 1e6, maxFiles: 100 } } } + if (path.includes('/builds/archive/')) return { status: 200, body: { state: 'unsupported', buildState: 'succeeded', steps: [], entries: [] } } + if (path.includes('/build-uploads/')) return { status: 200, body: { state: 'valid' } } + if (path.includes('/archive-deploys/')) return { status: 200, body: { state: 'live', imageRef: 'img@sha256:aa', url: '', endpointHost: HOST, ...ep, branch: 'main', group: 'dns' } } + if (path.includes('/archive-deploys')) return { status: 202, body: { operationId: 'op_1' } } + if (method === 'POST' && path === '/projects/p1/deploy') return { status: 200, body: { url: '', endpointHost: HOST, ...ep, branch: 'main', group: 'dns', machineId: 'm1' } } + throw new Error(`unexpected call: ${method} ${path}`) + }) + return { calls, rawRequest } + } + + function mockPlatform(rawRequest: ReturnType) { + const load = vi.spyOn(ApiClient, 'load').mockResolvedValue({ rawRequest } as unknown as ApiClient) + const requireProject = vi.spyOn(apiModule, 'requireProject').mockResolvedValue({ projectId: 'p1', branch: 'main' } as Awaited>) + const out: string[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((s) => { out.push(String(s)); return true }) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + return { load, requireProject, out } + } + + it('an invalid pair (udp+tcp) dies before ApiClient.load and requireProject', async () => { + const { calls, rawRequest } = fakeApi() + const { load, requireProject } = mockPlatform(rawRequest) + await expect(deploy(srcDir(), { port: '5000', endpoint: 'udp+tcp' })).rejects.toThrow() + expect(calls).toEqual([]) + expect(load).not.toHaveBeenCalled() + expect(requireProject).not.toHaveBeenCalled() + }) + + it('archive lane: a normalized udp reaches the body and the line prints udp://', async () => { + const { calls, rawRequest } = fakeApi() + const { out } = mockPlatform(rawRequest) + await deploy(srcDir(), { port: '5000', endpoint: ' UDP ' }) + expect(calls.find((c) => c.method === 'POST' && c.path === '/projects/p1/archive-deploys')?.body).toMatchObject({ endpoint: 'udp', port: 5000 }) + expect(out.join('')).toContain(`deployed img@sha256:aa -> udp://${HOST}:5000 (branch main, group dns)`) + }) + + it('archive lane --json carries endpointHost', async () => { + const { rawRequest } = fakeApi() + const { out } = mockPlatform(rawRequest) + await deploy(srcDir(), { port: '5000', endpoint: 'udp', json: true }) + expect(JSON.parse(out.join(''))).toMatchObject({ image: 'img@sha256:aa', url: '', endpointHost: HOST }) + }) + + it('image lane: tcp+udp rides the body and the line prints both schemes', async () => { + const { calls, rawRequest } = fakeApi() + const { out } = mockPlatform(rawRequest) + await deploy(undefined, { image: 'img:2', port: '5000', endpoint: 'tcp+udp' }) + expect(calls.find((c) => c.path === '/projects/p1/deploy')?.body).toMatchObject({ endpoint: 'tcp+udp', port: 5000 }) + expect(out.join('')).toContain(`deployed img:2 -> tcp://${HOST}:5000, udp://${HOST}:5000 (branch main, group dns)`) + }) + + it('image lane --json passes endpointHost through', async () => { + const { rawRequest } = fakeApi() + const { out } = mockPlatform(rawRequest) + await deploy(undefined, { image: 'img:2', port: '5000', endpoint: 'udp', json: true }) + expect(JSON.parse(out.join(''))).toEqual({ image: 'img:2', url: '', endpointHost: HOST, branch: 'main', group: 'dns', machineId: 'm1' }) + }) + + it('image lane, plain redeploy of a tcp service: the result endpoint restores the exact tcp:// line', async () => { + const { calls, rawRequest } = fakeApi('tcp') + const { out } = mockPlatform(rawRequest) + await deploy(undefined, { image: 'img:2', port: '5000' }) + expect((calls.find((c) => c.path === '/projects/p1/deploy')?.body as Record).endpoint).toBeUndefined() + expect(out.join('')).toBe(`deployed img:2 -> tcp://${HOST}:5000 (branch main, group dns)\n`) + }) + + it('archive lane, plain redeploy of a udp service: udp:// from the read, and --json carries endpoint', async () => { + const { rawRequest } = fakeApi('udp') + const { out } = mockPlatform(rawRequest) + await deploy(srcDir(), { port: '5000' }) + expect(out.join('')).toContain(`deployed img@sha256:aa -> udp://${HOST}:5000 (branch main, group dns)`) + out.length = 0 + await deploy(srcDir(), { port: '5000', json: true }) + expect(JSON.parse(out.join(''))).toMatchObject({ endpointHost: HOST, endpoint: 'udp' }) + }) +})