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
33 changes: 27 additions & 6 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 ?? '?'}`

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: A successful TCP deploy without an effective --port prints tcp://<host>:?, so the reported address is incomplete. Resolve the service/response port or require --port for TCP deploys.

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/deploy.ts, line 34:

<comment>A successful TCP deploy without an effective `--port` prints `tcp://<host>:?`, so the reported address is incomplete. Resolve the service/response port or require `--port` for TCP deploys.</comment>

<file context>
@@ -23,10 +23,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 ?? '?'}`
+  return r.url || '(no URL: a worker)'
+}
</file context>

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
Expand Down Expand Up @@ -162,6 +170,19 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise
if (dir && opts.image) die('pick one: a source <dir> OR --image <url>, not both')
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
// 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
Expand All @@ -182,23 +203,23 @@ 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) {
// 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 })

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 archive-lane --json output for a tcp service carries endpointHost but no port, so a JSON consumer cannot reconstruct the tcp://host:port address the human line prints, while an http deploy's port lives in the URL itself. Include the port (e.g. port: effOpts.port ? Number(effOpts.port) : undefined) alongside endpointHost so the dial address is recoverable from JSON.

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/deploy.ts, line 200:

<comment>The archive-lane `--json` output for a tcp service carries `endpointHost` but no port, so a JSON consumer cannot reconstruct the `tcp://host:port` address the human line prints, while an http deploy's port lives in the URL itself. Include the port (e.g. `port: effOpts.port ? Number(effOpts.port) : undefined`) alongside `endpointHost` so the dial address is recoverable from JSON.</comment>

<file context>
@@ -189,16 +197,16 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise
     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
</file context>

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))
.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} -> ${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)
}

Expand Down
33 changes: 28 additions & 5 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

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: --endpoint tcp without --port is accepted and sent with no port, so the platform's default (8080 per the --port help) applies. The --endpoint help says tcp needs --port, so refuse it locally before the network call.

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 164:

<comment>`--endpoint tcp` without `--port` is accepted and sent with no port, so the platform's default (8080 per the `--port` help) applies. The `--endpoint` help says tcp needs `--port`, so refuse it locally before the network call.</comment>

<file context>
@@ -143,6 +159,10 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO
   }
+  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.
</file context>
Suggested change
parseEndpoint(opts.endpoint)
if (parseEndpoint(opts.endpoint) === 'tcp' && opts.port === undefined) throw new Error('--endpoint tcp needs --port')

}
// 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')
Expand Down Expand Up @@ -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 <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives
Expand All @@ -194,15 +215,17 @@ 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` : ''}`
: s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}`
// 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<void> {
Expand Down
11 changes: 7 additions & 4 deletions src/deploy-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function archiveBuildSpec(hasDockerfile: boolean): ArchiveBuildSpec {
export type ArchiveRef = { archiveSha256: string; build: ArchiveBuildSpec }

type Api = Pick<ApiClient, 'rawRequest'>
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.
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 => {
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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('--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 @@ -549,6 +550,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('--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
Loading
Loading