From 80a2bec1c6b5bd3aabdae8df1c1613ecc74c7869 Mon Sep 17 00:00:00 2001 From: Andy Zhang Date: Sun, 25 Oct 2026 17:24:46 -0700 Subject: [PATCH 1/2] fix(deploy): a source deploy of a private service no longer exits 1 The platform answers url '' for a private-only HTTP service (insta-compute adapter), and the archive-deploy poller treated that as a missing URL unless --port 0 was passed, so a deploy that went live exited 1 with "no image or URL". Accept an empty url; keep refusing a missing image, a non-string url, and a v6-endpoint deploy that comes back with neither a URL nor a host. The result line now says "no public URL: a private service" instead of calling it a worker. Refs SUP2-536, SUP2-543. Co-Authored-By: Claude Opus 5.5 --- src/commands/deploy.ts | 5 ++++- src/deploy-archive.ts | 5 ++++- test/deploy-archive.test.ts | 17 +++++++++++++---- test/endpoint-tcp.test.ts | 7 +++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 36bd52d..e98c1de 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -40,7 +40,10 @@ export function deployedTarget(r: { url?: string; endpointHost?: string | null; 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)' + if (r.url) return r.url + // No URL is a worker (port 0) or a private-only service; a redeploy that passed no --port can be either. + if (port === '0') return '(no URL: a worker)' + return `(no public URL: ${port === undefined ? 'a worker or ' : ''}a private service — \`insta service list\` shows its private address)` } // What a source directory resolved to. flyctl and local-docker build an image locally and hand it diff --git a/src/deploy-archive.ts b/src/deploy-archive.ts index 3e3a253..0f9e1c1 100644 --- a/src/deploy-archive.ts +++ b/src/deploy-archive.ts @@ -170,7 +170,10 @@ export async function deployArchive( // The endpoint the service is on (instacloud-platform#681), so the address gets its scheme; // an older platform omits it, and a value this CLI does not know is treated as absent. const endpoint = endpointHost && isV6Endpoint(res.body?.endpoint) ? res.body.endpoint as Exclude : undefined - if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) { + // An empty url is an answer, not a missing one: a worker has none, and neither does a + // private-only HTTP service (the platform's insta-compute adapter returns '' for one). Only a + // deploy that asked for a v6 endpoint is owed something in its place: the host. + if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && !endpointHost && isV6Endpoint(opts.endpoint))) { 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 => { diff --git a/test/deploy-archive.test.ts b/test/deploy-archive.test.ts index 96ce37b..4296cd2 100644 --- a/test/deploy-archive.test.ts +++ b/test/deploy-archive.test.ts @@ -120,19 +120,28 @@ describe('deployArchive — the gated call and the poll after it', () => { expect(process.exitCode).toBe(2) }) - it('accepts an empty worker URL but rejects it for a TCP deploy', async () => { + it('accepts the empty URL of a worker and of a private HTTP service', async () => { + // A private-only service on a real port goes live with url '' (SUP2-536, SUP2-543). for (const port of ['0', '8080']) { const { api: a, calls } = api([ { status: 202, body: { operationId: 'op_1' } }, { status: 200, body: { ...live, url: '' } }, ]) - const result = deployArchive(a, 'p1', ref, 'main', { port }, Date.now, noWait) - if (port === '0') await expect(result).resolves.toMatchObject({ url: '', image: live.imageRef }) - else await expect(result).rejects.toThrow(/no image or URL/) + await expect(deployArchive(a, 'p1', ref, 'main', { port }, Date.now, noWait)).resolves.toMatchObject({ url: '', image: live.imageRef }) expect(calls[0]!.body.port).toBe(Number(port)) } }) + it('still rejects a live deploy with no image or a non-string URL', async () => { + for (const body of [{ ...live, imageRef: '' }, { ...live, url: undefined }]) { + const { api: a } = api([ + { status: 202, body: { operationId: 'op_1' } }, + { status: 200, body }, + ]) + await expect(deployArchive(a, 'p1', ref, 'main', { port: '8080' }, Date.now, noWait)).rejects.toThrow(/no image or URL/) + } + }) + it('carries every deploy option in the one gated body', async () => { const { api: a, calls } = api([ { status: 202, body: { status: 'accepted', operationId: 'op_1', state: 'queued' } }, diff --git a/test/endpoint-tcp.test.ts b/test/endpoint-tcp.test.ts index da798c0..05b7d82 100644 --- a/test/endpoint-tcp.test.ts +++ b/test/endpoint-tcp.test.ts @@ -73,6 +73,8 @@ describe('deploy --endpoint', () => { ['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)'], + ['a private HTTP service', { url: '' }, '8080', undefined, '(no public URL: a private service — `insta service list` shows its private address)'], + ['a redeploy with no URL and no --port', { url: '' }, undefined, undefined, '(no public URL: a worker or a private service — `insta service list` shows its private address)'], ])('reports %s', (_label, r, port, endpoint, want) => { expect(deployedTarget(r, port, endpoint)).toBe(want) }) @@ -94,10 +96,11 @@ describe('archive deploy of a tcp service', () => { 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. + // The control: a tcp deploy answered with neither a URL nor a tcp host is still the "no URL" + // failure it always was. (Without --endpoint an empty URL is a private HTTP service: SUP2-543.) 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/) + await expect(deployArchive(a, 'p1', ref, 'main', { port: '7000', endpoint: 'tcp' }, Date.now, noWait)).rejects.toThrow(/no image or URL/) }) }) From f7b55c6b2cef974c92ddc997530dd174904112ab Mon Sep 17 00:00:00 2001 From: Andy Zhang <16486422+v01dstar@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:33:24 +0000 Subject: [PATCH 2/2] fix(deploy): refuse an empty URL with no host when the platform reports a v6 endpoint A redeploy of a tcp/udp service passes no --endpoint, so the guard also checks the endpoint the poll reports. The private-service hint now names `insta compute public-access `. Co-Authored-By: andy-bot --- src/commands/deploy.ts | 2 +- src/deploy-archive.ts | 5 +++-- test/endpoint-tcp.test.ts | 11 +++++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index e98c1de..26bb2b0 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -43,7 +43,7 @@ export function deployedTarget(r: { url?: string; endpointHost?: string | null; if (r.url) return r.url // No URL is a worker (port 0) or a private-only service; a redeploy that passed no --port can be either. if (port === '0') return '(no URL: a worker)' - return `(no public URL: ${port === undefined ? 'a worker or ' : ''}a private service — \`insta service list\` shows its private address)` + return `(no public URL: ${port === undefined ? 'a worker or ' : ''}a private service — \`insta compute public-access \` shows its private address)` } // What a source directory resolved to. flyctl and local-docker build an image locally and hand it diff --git a/src/deploy-archive.ts b/src/deploy-archive.ts index 0f9e1c1..7979da4 100644 --- a/src/deploy-archive.ts +++ b/src/deploy-archive.ts @@ -172,8 +172,9 @@ export async function deployArchive( const endpoint = endpointHost && isV6Endpoint(res.body?.endpoint) ? res.body.endpoint as Exclude : undefined // An empty url is an answer, not a missing one: a worker has none, and neither does a // private-only HTTP service (the platform's insta-compute adapter returns '' for one). Only a - // deploy that asked for a v6 endpoint is owed something in its place: the host. - if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && !endpointHost && isV6Endpoint(opts.endpoint))) { + // v6-endpoint service is owed something in its place, the host: one this deploy asked for + // with --endpoint, or one the platform reports (a redeploy keeps its endpoint and passes none). + if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && !endpointHost && (isV6Endpoint(opts.endpoint) || isV6Endpoint(res.body?.endpoint)))) { 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 => { diff --git a/test/endpoint-tcp.test.ts b/test/endpoint-tcp.test.ts index 05b7d82..31e0224 100644 --- a/test/endpoint-tcp.test.ts +++ b/test/endpoint-tcp.test.ts @@ -73,8 +73,8 @@ describe('deploy --endpoint', () => { ['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)'], - ['a private HTTP service', { url: '' }, '8080', undefined, '(no public URL: a private service — `insta service list` shows its private address)'], - ['a redeploy with no URL and no --port', { url: '' }, undefined, undefined, '(no public URL: a worker or a private service — `insta service list` shows its private address)'], + ['a private HTTP service', { url: '' }, '8080', undefined, '(no public URL: a private service — `insta compute public-access ` shows its private address)'], + ['a redeploy with no URL and no --port', { url: '' }, undefined, undefined, '(no public URL: a worker or a private service — `insta compute public-access ` shows its private address)'], ])('reports %s', (_label, r, port, endpoint, want) => { expect(deployedTarget(r, port, endpoint)).toBe(want) }) @@ -102,6 +102,13 @@ describe('archive deploy of a tcp service', () => { const { api: a } = api({ url: '' }) await expect(deployArchive(a, 'p1', ref, 'main', { port: '7000', endpoint: 'tcp' }, Date.now, noWait)).rejects.toThrow(/no image or URL/) }) + + // A redeploy passes no --endpoint, but the platform reports the endpoint the service kept: a tcp + // service answered with neither a URL nor a host is still refused, not taken for a private HTTP one. + it('refuses an empty URL with no host when the platform reports a v6 endpoint on a redeploy', async () => { + const { api: a } = api({ url: '', endpoint: 'tcp' }) + 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