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
28 changes: 18 additions & 10 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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)'
}

Expand Down Expand Up @@ -171,12 +179,12 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise
if (!dir && !opts.image) die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]')

// 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) {
Expand Down Expand Up @@ -210,16 +218,16 @@ 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))
.catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e })
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)
}

Expand Down
35 changes: 26 additions & 9 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Endpoint, 'http'> => 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(', ')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The UDP/tcp+udp address can print :undefined or :null when the service response has a host but no port. Use the same s.port ?? '?' fallback as the no-host branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/commands/services.ts, line 148:

<comment>The UDP/tcp+udp address can print `:undefined` or `:null` when the service response has a host but no port. Use the same `s.port ?? '?'` fallback as the no-host branch.</comment>

<file context>
@@ -116,21 +116,38 @@ function defaultDatabasePort(type: string): number {
+  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(', ')
+}
+
</file context>
Suggested change
return protos.map((pr) => `${pr}://${s.endpoint_host}:${s.port}`).join(', ')
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.
Expand Down Expand Up @@ -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}`
}

Expand All @@ -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' : ''
Expand Down
12 changes: 8 additions & 4 deletions src/deploy-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Endpoint, 'http'>; branch: string; group: string; machineId?: string }
export type ArchiveDeployResult = DeployOutcome | { failed: string }

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Endpoint, 'http'> : 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`')
}
Expand All @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>', 'compute only: run this container image at creation')
.option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
.option('--endpoint <http|tcp>', '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 <http|tcp|udp|tcp+udp>', '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 <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
Expand Down Expand Up @@ -559,7 +559,7 @@ buildCmd.command('logs <build-id>').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 <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>', '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 <http|tcp>', '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 <http|tcp|udp|tcp+udp>', '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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: deploy --help enables UDP without warning that a wake-held datagram is re-sent until the service replies, so an app may process it twice; receive-only services can also sleep. Add the UDP replay and --always-on caveats here so users switching endpoints through deploy see these operational constraints.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/index.ts, line 553:

<comment>`deploy --help` enables UDP without warning that a wake-held datagram is re-sent until the service replies, so an app may process it twice; receive-only services can also sleep. Add the UDP replay and `--always-on` caveats here so users switching endpoints through deploy see these operational constraints.</comment>

<file context>
@@ -550,7 +550,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
   .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>', '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 <http|tcp>', '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 <http|tcp|udp|tcp+udp>', '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)')
</file context>
Suggested change
.option('--endpoint <http|tcp|udp|tcp+udp>', '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('--endpoint <http|tcp|udp|tcp+udp>', '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. UDP wake-held datagrams are re-sent until the service first replies (an app may see them twice); receive-only UDP services need --always-on')

.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)))
Expand Down
20 changes: 10 additions & 10 deletions test/endpoint-tcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
})
})

Expand All @@ -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)
})
Expand Down Expand Up @@ -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)
})
})

Expand Down
Loading
Loading