Repository navigation
service add / deploy: --endpoint http|tcp, and show a tcp service's tcp:// address #368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ?? '?'}` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A successful TCP deploy without an effective Prompt for AI agents |
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The archive-lane Prompt for AI agents |
||
| 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) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
|
||||||
| } | ||||||
| // 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 <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives | ||||||
|
|
@@ -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> { | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.