From 71c05baa23196189f27bc84aaaa0f0e0f8c5f9b6 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 9 Oct 2026 13:29:42 -0700 Subject: [PATCH 1/3] service add / deploy: --endpoint http|tcp; show a tcp service's tcp:// address Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/deploy.ts | 18 ++++++-- src/commands/services.ts | 33 +++++++++++-- src/deploy-archive.ts | 11 +++-- src/index.ts | 2 + test/endpoint-tcp.test.ts | 97 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 test/endpoint-tcp.test.ts diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 3c2fe85..cc47f3f 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -6,9 +6,9 @@ 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 { parsePort } from './services.js' +import { parseEndpoint, parsePort } from './services.js' -type DeployOpts = { image?: string; branch?: string; group?: string; port?: string; websocket?: boolean; replaceSource?: boolean; json?: boolean } +type DeployOpts = { image?: string; branch?: string; group?: string; port?: string; websocket?: boolean; endpoint?: string; replaceSource?: boolean; json?: boolean } // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress // line moves to stderr. @@ -23,10 +23,18 @@ export function deployRequestBody(source: { image: string }, branch: string, opt group: opts.group, port: opts.port ? Number(opts.port) : undefined, websocket: opts.websocket ? true : undefined, + endpoint: opts.endpoint !== undefined ? parseEndpoint(opts.endpoint) : undefined, replaceSource: opts.replaceSource ? true : undefined, } } +// 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 ?? '?'}` + return r.url || '(no URL: a worker)' +} + // What a source directory resolved to. flyctl and local-docker build an image locally and hand it // back for the ordinary `/deploy` call. The archive lane is different in kind: its one gated call // enqueues the build AND the deploy as a single operation, so by the time it returns the deploy @@ -189,8 +197,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, branch: d.branch, group: d.group, machineId: d.machineId }) - info(`deployed ${d.image} -> ${d.url} (branch ${d.branch}, group ${d.group})`) + 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})`) return } const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts)) @@ -198,7 +206,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} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`) + info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port)} (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 4f8fb99..f96470b 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -114,7 +114,22 @@ function defaultDatabasePort(type: string): number { // ---- commands ---- -export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; region?: string; alwaysOn?: boolean; volume?: string; mountPath?: string; pgVersion?: string; json?: boolean } +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' { + const v = raw.trim().toLowerCase() + if (v !== 'http' && v !== 'tcp') throw new Error(`--endpoint must be http or tcp, got: ${raw}`) + return v +} + +// 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)` +} // 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 @@ -123,6 +138,7 @@ export function servicesAddRequestBody(type: string, name: string, branch: strin return { type, name, ...(branch ? { branch } : {}), public: !!opts.public, ...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}), + ...(opts.endpoint !== undefined ? { endpoint: parseEndpoint(opts.endpoint) } : {}), ...(opts.region ? { region: opts.region } : {}), // Sent whenever the flag was given, false included: the birth default is platform config // (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on @@ -143,6 +159,10 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO if (type !== 'compute') throw new Error('--port is only valid for compute services') parsePort(opts.port, { allowZero: true }) } + if (opts.endpoint !== undefined) { + if (type !== 'compute') throw new Error('--endpoint is only valid for compute services') + parseEndpoint(opts.endpoint) + } // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only. if (opts.alwaysOn !== undefined && type !== 'compute') throw new Error(alwaysOnTypeError(type)) if (opts.mountPath !== undefined && (type !== 'compute' || opts.volume === undefined)) throw new Error('--mount-path requires --volume on a compute service') @@ -176,13 +196,14 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO // The `services add` success line. Pure, so the badge's placement is unit-tested: a template string // nothing asserts on silently loses a segment. -export function serviceAddedLine(type: string, name: string, branch: string | undefined, svc: { id: string; type: string; public?: boolean; image?: string; port?: number; volume_gib?: number | null; volume_mount_path?: string | null; region?: string; domain?: string; pg_version?: number | null }): string { +export function serviceAddedLine(type: string, name: string, branch: string | undefined, svc: { id: string; type: string; public?: boolean; image?: string; port?: number; volume_gib?: number | null; volume_mount_path?: string | null; region?: string; domain?: string; pg_version?: number | null; endpoint?: string | null; endpoint_host?: string | null }): string { const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '' const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '' 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) : '' - return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${svc.domain ? ` — ${svc.domain}` : ''}` + const where = svc.endpoint === 'tcp' ? ` — ${tcpAddress(svc)}` : svc.domain ? ` — ${svc.domain}` : '' + return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}` } // ` pg ` for a postgres row, or '' when the platform sent nothing usable. The field arrives @@ -194,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 }): 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 }): 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` : ''}` @@ -202,7 +223,9 @@ 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) : '' - return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}` + // 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}` } export async function servicesList(opts: { json?: boolean; branch?: string }): Promise { diff --git a/src/deploy-archive.ts b/src/deploy-archive.ts index f1a47ea..359d627 100644 --- a/src/deploy-archive.ts +++ b/src/deploy-archive.ts @@ -17,7 +17,7 @@ export function archiveBuildSpec(hasDockerfile: boolean): ArchiveBuildSpec { export type ArchiveRef = { archiveSha256: string; build: ArchiveBuildSpec } type Api = Pick -type Opts = { branch?: string; group?: string; json?: boolean; port?: string; websocket?: boolean; replaceSource?: boolean } +type Opts = { branch?: string; group?: string; json?: boolean; port?: string; websocket?: boolean; endpoint?: string; replaceSource?: boolean } // Plain fetch, never the api client: the presigned URL carries its own signature and the platform // bearer must not be sent to a bucket. @@ -89,7 +89,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; branch: string; group: string; machineId?: string } +export type DeployOutcome = { image: string; url: string; endpointHost?: string; branch: string; group: string; machineId?: string } export type ArchiveDeployResult = DeployOutcome | { failed: string } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -119,6 +119,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, replaceSource: opts.replaceSource === true ? true : undefined, }) // An approval is a 202 too, told apart by its status word; anything else here is our operation. @@ -163,7 +164,9 @@ export async function deployArchive( if (state === 'live') { const image = res.body?.imageRef const url = res.body?.url - if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) { + // An endpoint tcp service has no URL: its answer is the tcp host instead. A worker has neither. + const endpointHost = typeof res.body?.endpointHost === 'string' && res.body.endpointHost ? res.body.endpointHost as string : 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`') } const optionalString = (field: string, v: unknown): string | undefined => { @@ -172,7 +175,7 @@ export async function deployArchive( return v } return { - image, url, + image, url, ...(endpointHost ? { endpointHost } : {}), 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 b075dc8..7bf07d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,6 +155,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('--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)') @@ -549,6 +550,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('--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 new file mode 100644 index 0000000..eaff472 --- /dev/null +++ b/test/endpoint-tcp.test.ts @@ -0,0 +1,97 @@ +// --endpoint tcp: a compute service reached as raw TCP on its port at its own IPv6 address. The +// platform owns which ports are allowed; the CLI only spells the flag, sends it, and shows where +// such a service answers (it has no URL). +import { describe, it, expect, afterEach } from 'vitest' +import { parseEndpoint, servicesAdd, servicesAddRequestBody, serviceListLine, serviceAddedLine, tcpAddress } from '../src/commands/services.js' +import { deployRequestBody, deployedTarget } from '../src/commands/deploy.js' +import { deployArchive } from '../src/deploy-archive.js' + +afterEach(() => { process.exitCode = undefined }) + +const HOST = 'prod-main-game-aa6a3.ip.us-east-1.compute.instacloud.tech' + +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/) + }) +}) + +describe('service add --endpoint', () => { + it('sends the endpoint only when the flag was given', () => { + expect(servicesAddRequestBody('compute', 'game', 'main', { port: '25565', endpoint: 'tcp' })).toMatchObject({ port: 25565, endpoint: 'tcp' }) + expect(servicesAddRequestBody('compute', 'api', 'main', { port: '3000' })).not.toHaveProperty('endpoint') + }) + + // Refused before any config or network access: the test has neither, so reaching either would + // 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'], + ])('%s fails locally', async (_label, type, opts, error) => { + await expect(servicesAdd(type, 'x', opts)).rejects.toThrow(error) + }) + + it('the added line shows the tcp address, not a domain', () => { + const line = serviceAddedLine('compute', 'game', 'main', { id: 's1', type: 'compute', image: 'mc', port: 25565, endpoint: 'tcp', endpoint_host: HOST }) + expect(line).toContain(`— tcp://${HOST}:25565`) + }) +}) + +describe('service list', () => { + const base = { type: 'compute', name: 'game', status: 'active', id: 's1', machine_count: 1, image: 'mc', port: 25565 } + it('a tcp row shows where to dial it', () => { + expect(serviceListLine({ ...base, endpoint: 'tcp', endpoint_host: HOST })).toContain(` tcp://${HOST}:25565 s1`) + }) + it('a tcp row not yet deployed says so instead of inventing an address', () => { + expect(serviceListLine({ ...base, endpoint: 'tcp', endpoint_host: null })).toContain('tcp/25565 (address after the first deploy)') + }) + it('an http row is unchanged', () => { + expect(serviceListLine({ ...base, domain: 'game.example.com' })).toBe('compute/game [active] x1 running mc:25565 game.example.com s1') + expect(serviceListLine({ ...base, endpoint: 'http', domain: 'game.example.com' })).toBe('compute/game [active] x1 running mc:25565 game.example.com s1') + }) + it('tcpAddress', () => { + expect(tcpAddress({ endpoint_host: HOST, port: 7000 })).toBe(`tcp://${HOST}:7000`) + }) +}) + +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/) + }) + + 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) + }) +}) + +describe('archive deploy of a tcp 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: 'game', ...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('sends the endpoint and accepts an empty URL when the platform names the tcp host', async () => { + const { api: a, calls } = api({ url: '', endpointHost: HOST }) + await expect(deployArchive(a, 'p1', ref, 'main', { port: '7000', endpoint: 'tcp' }, Date.now, noWait)).resolves.toMatchObject({ url: '', endpointHost: HOST }) + expect(calls[0]!.body).toMatchObject({ endpoint: 'tcp', port: 7000 }) + }) + + // The control: the same empty URL with no tcp host is still the "no URL" failure it always was. + it('still refuses an empty URL with no tcp host', async () => { + const { api: a } = api({ url: '' }) + await expect(deployArchive(a, 'p1', ref, 'main', { port: '7000' }, Date.now, noWait)).rejects.toThrow(/no image or URL/) + }) +}) From e1b0de30c906708432edec17b4432790e6fc6b17 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 9 Oct 2026 21:01:42 +0000 Subject: [PATCH 2/3] fix(deploy): validate --endpoint before any network/build work deploy() handed the raw --endpoint flag to prepareSource() unvalidated; the archive lane's request body silently turned anything other than an exact 'http'/'tcp' into undefined, so a typo like --endpoint tpc built and deployed using the service's existing endpoint instead of failing locally, and accepted-but-unnormalized values like ' TCP ' were lost the same way. Parse and normalize the flag once, before prepareSource's discovery/pack/upload calls, and pass the normalized value to both lanes. --- src/commands/deploy.ts | 12 ++++++- test/endpoint-tcp.test.ts | 66 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index cc47f3f..ca589e1 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -181,6 +181,16 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise } catch (e) { die(`--${(e as Error).message}`) } + // 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 + // undefined, which let a typo like --endpoint tpc build and deploy using the service's existing + // endpoint instead of failing locally. + let endpoint: 'http' | 'tcp' | undefined + try { + endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint) + } catch (e) { + die((e as Error).message) + } if (dir && port === undefined) { const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile') const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined @@ -190,7 +200,7 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise } } - const effOpts = { ...opts, port: port?.toString() } + const effOpts = { ...opts, port: port?.toString(), endpoint } const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image! } if (!source) return // an approval is pending; the user approves and re-runs if ('deployed' in source) { diff --git a/test/endpoint-tcp.test.ts b/test/endpoint-tcp.test.ts index eaff472..9a37a14 100644 --- a/test/endpoint-tcp.test.ts +++ b/test/endpoint-tcp.test.ts @@ -1,12 +1,17 @@ // --endpoint tcp: a compute service reached as raw TCP on its port at its own IPv6 address. The // platform owns which ports are allowed; the CLI only spells the flag, sends it, and shows where // such a service answers (it has no URL). -import { describe, it, expect, afterEach } from 'vitest' +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, serviceListLine, serviceAddedLine, tcpAddress } from '../src/commands/services.js' -import { deployRequestBody, deployedTarget } from '../src/commands/deploy.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(() => { process.exitCode = undefined }) +afterEach(() => { vi.restoreAllMocks(); process.exitCode = undefined }) const HOST = 'prod-main-game-aa6a3.ip.us-east-1.compute.instacloud.tech' @@ -95,3 +100,58 @@ describe('archive deploy of a tcp service', () => { await expect(deployArchive(a, 'p1', ref, 'main', { port: '7000' }, Date.now, noWait)).rejects.toThrow(/no image or URL/) }) }) + +// The archive body's own `=== 'http' || === 'tcp'` check silently drops anything else to +// undefined, which let `insta deploy . --endpoint tpc` sail through using the service's existing +// endpoint instead of failing locally. deploy() must validate and normalize --endpoint itself, +// before prepareSource's discovery/build/upload network calls, not leave it to that check. +describe('deploy() validates --endpoint at command entry, for the directory (archive) lane', () => { + function srcDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'insta-endpoint-')) + writeFileSync(join(dir, 'app.js'), 'console.log(1)\n') + return dir + } + + // A minimal archive-lane fake: discovery names the lane, the object is already uploaded, and + // the deploy operation is live on the first poll — the shape prepareSource needs to resolve + // without ever reaching the real network. + function fakeApi() { + 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, branch: 'main', group: 'api' } } + if (path.includes('/archive-deploys')) return { status: 202, body: { operationId: 'op_1' } } + throw new Error(`unexpected call: ${method} ${path}`) + }) + return { calls, rawRequest } + } + + function mockPlatform(rawRequest: ReturnType) { + vi.spyOn(ApiClient, 'load').mockResolvedValue({ rawRequest } as unknown as ApiClient) + vi.spyOn(apiModule, 'requireProject').mockResolvedValue({ projectId: 'p1', branch: 'main' } as Awaited>) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + } + + it('an invalid --endpoint dies before any network call — no discovery, no pack, no upload', async () => { + const { calls, rawRequest } = fakeApi() + mockPlatform(rawRequest) + + await expect(deploy(srcDir(), { port: '7000', endpoint: 'tpc' })).rejects.toThrow() + + expect(calls).toEqual([]) + }) + + it("a normalized endpoint (' TCP ', as parseEndpoint's own tests accept) reaches the archive body as 'tcp'", async () => { + const { calls, rawRequest } = fakeApi() + mockPlatform(rawRequest) + + await deploy(srcDir(), { port: '7000', endpoint: ' TCP ' }) + + const started = calls.find((c) => c.method === 'POST' && c.path === '/projects/p1/archive-deploys') + expect(started?.body).toMatchObject({ endpoint: 'tcp', port: 7000 }) + }) +}) From 8377360057d8a0c3d80e42050cdc7f82dfa296ac Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 9 Oct 2026 21:46:08 +0000 Subject: [PATCH 3/3] fix(deploy): validate --endpoint before requireProject/ApiClient.load requireProject() can resolve a project for an unlinked directory and persist an automatic link as a side effect. Validating --endpoint after it meant an invalid value like --endpoint tpc could still touch the network and change local project config before being rejected. Move the check ahead of ApiClient.load()/requireProject(), and assert in the regression test that neither is called for an invalid endpoint. --- src/commands/deploy.ts | 23 +++++++++++++---------- test/endpoint-tcp.test.ts | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index ca589e1..1b50be3 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -170,6 +170,19 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise if (dir && opts.image) die('pick one: a source

OR --image , not both') 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 + // 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 + try { + endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint) + } catch (e) { + die((e as Error).message) + } + const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch @@ -181,16 +194,6 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise } catch (e) { die(`--${(e as Error).message}`) } - // 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 - // undefined, which let a typo like --endpoint tpc build and deploy using the service's existing - // endpoint instead of failing locally. - let endpoint: 'http' | 'tcp' | undefined - try { - endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint) - } catch (e) { - die((e as Error).message) - } if (dir && port === undefined) { const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile') const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined diff --git a/test/endpoint-tcp.test.ts b/test/endpoint-tcp.test.ts index 9a37a14..8cab17d 100644 --- a/test/endpoint-tcp.test.ts +++ b/test/endpoint-tcp.test.ts @@ -130,19 +130,27 @@ describe('deploy() validates --endpoint at command entry, for the directory (arc } function mockPlatform(rawRequest: ReturnType) { - vi.spyOn(ApiClient, 'load').mockResolvedValue({ rawRequest } as unknown as ApiClient) - vi.spyOn(apiModule, 'requireProject').mockResolvedValue({ projectId: 'p1', branch: 'main' } as Awaited>) + 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>) vi.spyOn(process.stdout, 'write').mockImplementation(() => true) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + return { load, requireProject } } - it('an invalid --endpoint dies before any network call — no discovery, no pack, no upload', async () => { + // requireProject() resolves a project for an unlinked directory and can persist an automatic + // link as a side effect — so an invalid --endpoint must die before it (and ApiClient.load) ever + // run, not merely before prepareSource's own network calls. A mock that always resolves hides a + // regression here (it never touches the network either way), so the mocks themselves are + // asserted un-called, not just their downstream calls. + it('an invalid --endpoint dies before any network call, before ApiClient.load, and before requireProject can resolve/link a project', async () => { const { calls, rawRequest } = fakeApi() - mockPlatform(rawRequest) + const { load, requireProject } = mockPlatform(rawRequest) await expect(deploy(srcDir(), { port: '7000', endpoint: 'tpc' })).rejects.toThrow() expect(calls).toEqual([]) + expect(load).not.toHaveBeenCalled() + expect(requireProject).not.toHaveBeenCalled() }) it("a normalized endpoint (' TCP ', as parseEndpoint's own tests accept) reaches the archive body as 'tcp'", async () => {