diff --git a/src/commands/compute/deploy.test.ts b/src/commands/compute/deploy.test.ts index 559fd90..4eaccf3 100644 --- a/src/commands/compute/deploy.test.ts +++ b/src/commands/compute/deploy.test.ts @@ -16,13 +16,65 @@ vi.mock('../../lib/errors.js', async (importOriginal) => { import { Command } from 'commander'; import { registerComputeDeployCommand } from './deploy.js'; +/** + * Route the mock by URL rather than by call order. + * + * `compute deploy` now asks /api/metadata what the provider can do before shaping + * the request, so an assertion keyed on `mock.calls[1]` breaks as soon as another + * call is added. Routing by URL survives that. + */ +function routeOssFetch(capabilities?: Record) { + const caps = + capabilities ?? { + scaleToZero: true, + regions: true, + ingressModes: ['host'], + sourceBuild: 'flyctl', + deployTokenIssuance: true, + }; + ossFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === '/api/metadata') { + return Promise.resolve({ + json: async () => ({ compute: { defaultProvider: 'test', providers: { test: caps } } }), + }); + } + if (url === '/api/compute/services' && !init?.method) { + return Promise.resolve({ json: async () => [] }); + } + return Promise.resolve({ + json: async () => ({ + id: 'svc-1', + name: 'cache', + status: 'started', + endpointUrl: 'https://cache.fly.dev', + port: 6379, + service: { name: 'cache', status: 'running' }, + imageTag: 'insforge-x/cache:abc', + logs: ['Step 1/2 : FROM alpine'], + }), + }); + }); +} + +/** Body of the POST that creates or prepares the service. */ +function createCallBody(): Record { + const call = ossFetchMock.mock.calls.find( + ([url, init]) => + typeof url === 'string' && + url.startsWith('/api/compute/services') && + (init as { method?: string } | undefined)?.method === 'POST' + ); + if (!call) { + throw new Error('no create/prepare POST was made'); + } + return JSON.parse((call[1] as { body: string }).body) as Record; +} + + describe('compute deploy --protocol', () => { beforeEach(() => { ossFetchMock.mockReset(); - ossFetchMock.mockResolvedValueOnce({ json: async () => [] }); // initial list - ossFetchMock.mockResolvedValueOnce({ - json: async () => ({ name: 'cache', status: 'started', endpointUrl: 'https://cache.fly.dev', port: 6379 }), - }); + routeOssFetch(); }); it('includes protocol="tcp" in request body when --protocol tcp', async () => { @@ -37,8 +89,7 @@ describe('compute deploy --protocol', () => { '--protocol', 'tcp', '--port', '6379', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect(body.protocol).toBe('tcp'); expect(body.port).toBe(6379); }); @@ -52,8 +103,7 @@ describe('compute deploy --protocol', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'web', '--port', '8080', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect('protocol' in body).toBe(false); }); @@ -74,10 +124,7 @@ describe('compute deploy --protocol', () => { describe('compute deploy --always-on / --scale-to-zero', () => { beforeEach(() => { ossFetchMock.mockReset(); - ossFetchMock.mockResolvedValueOnce({ json: async () => [] }); // initial list - ossFetchMock.mockResolvedValueOnce({ - json: async () => ({ name: 'api', status: 'running', endpointUrl: 'https://api.fly.dev', port: 8080, scaleToZero: false }), - }); + routeOssFetch(); }); it('includes scaleToZero=false in request body when --always-on', async () => { @@ -89,8 +136,7 @@ describe('compute deploy --always-on / --scale-to-zero', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'api', '--always-on', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect(body.scaleToZero).toBe(false); }); @@ -103,8 +149,7 @@ describe('compute deploy --always-on / --scale-to-zero', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'api', '--scale-to-zero', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect(body.scaleToZero).toBe(true); }); @@ -117,8 +162,7 @@ describe('compute deploy --always-on / --scale-to-zero', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'api', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect('scaleToZero' in body).toBe(false); }); @@ -135,3 +179,66 @@ describe('compute deploy --always-on / --scale-to-zero', () => { ).rejects.toThrow(/mutually exclusive/); }); }); + +describe('compute deploy against a single-host provider', () => { + const DOCKER = { + scaleToZero: false, + regions: false, + ingressModes: ['none', 'port', 'host'], + sourceBuild: 'context-upload', + deployTokenIssuance: false, + }; + + beforeEach(() => { + ossFetchMock.mockReset(); + }); + + // Sending a region to a provider with one host records a choice that never takes + // effect, and nothing on screen says so. + it('omits region when the provider has none', async () => { + routeOssFetch(DOCKER); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await cmd.parseAsync([ + 'node', 'lim', 'compute', 'deploy', + '--image', 'nginx:alpine', '--name', 'web', '--port', '8080', + ]); + expect(createCallBody()).not.toHaveProperty('region'); + }); + + it('omits scaleToZero when the provider cannot honour it', async () => { + routeOssFetch(DOCKER); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await cmd.parseAsync([ + 'node', 'lim', 'compute', 'deploy', + '--image', 'nginx:alpine', '--name', 'web', '--port', '8080', '--always-on', + ]); + expect(createCallBody()).not.toHaveProperty('scaleToZero'); + }); + + // The gate must not be a blanket removal — a provider with regions still gets one. + it('still sends region to a provider that has regions', async () => { + routeOssFetch(); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await cmd.parseAsync([ + 'node', 'lim', 'compute', 'deploy', + '--image', 'nginx:alpine', '--name', 'web', '--port', '8080', '--region', 'lhr', + ]); + expect(createCallBody().region).toBe('lhr'); + }); + + it('refuses source mode when the provider cannot build at all', async () => { + routeOssFetch({ ...DOCKER, sourceBuild: 'none' }); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await expect( + cmd.parseAsync(['node', 'lim', 'compute', 'deploy', '.', '--name', 'web']) + ).rejects.toThrow(/cannot build from source/); + }); +}); diff --git a/src/commands/compute/deploy.ts b/src/commands/compute/deploy.ts index c99be92..5c421c4 100644 --- a/src/commands/compute/deploy.ts +++ b/src/commands/compute/deploy.ts @@ -12,6 +12,8 @@ import { ensureFlyctlAvailable, flyctlBuildAndPush, } from '../../lib/flyctl.js'; +import { fetchComputeCapabilities } from '../../lib/compute-capabilities.js'; +import { packBuildContext } from '../../lib/build-context.js'; // `compute deploy` has two modes: // @@ -44,7 +46,7 @@ export function registerComputeDeployCommand(computeCmd: Command): void { 'shared-1x' ) .option('--memory ', 'Memory in MB', '512') - .option('--region ', 'Fly.io region', 'iad') + .option('--region ', 'Region (providers that have more than one)', 'iad') .option('--env ', 'Env vars as JSON object') .option( '--env-file ', @@ -127,16 +129,36 @@ export function registerComputeDeployCommand(computeCmd: Command): void { envVars = parseEnvFile(resolve(opts.envFile)); } + // Ask the backend what its provider can do before shaping the request. A + // self-hosted Docker daemon has one region and no scale-to-zero, and + // sending those anyway records a choice that never takes effect. + const { provider, capabilities } = await fetchComputeCapabilities(); + if (!capabilities.regions && process.argv.includes('--region') && !json) { + outputInfo( + `Ignoring --region: the ${provider ?? 'configured'} provider runs on a single host.` + ); + } + if (!capabilities.scaleToZero && scaleToZero === false && !json) { + outputInfo( + `Ignoring --always-on: the ${provider ?? 'configured'} provider has no ` + + 'scale-to-zero, so services already run continuously.' + ); + } + const baseBody: Record = { name: opts.name, port, cpu: opts.cpu, memory, - region: opts.region, + // Omitted where it means nothing, so the stored row does not claim a + // region the provider never honoured. + ...(capabilities.regions ? { region: opts.region } : {}), }; if (envVars) baseBody.envVars = envVars; if (opts.protocol === 'tcp') baseBody.protocol = 'tcp'; - if (scaleToZero !== undefined) baseBody.scaleToZero = scaleToZero; + if (scaleToZero !== undefined && capabilities.scaleToZero) { + baseBody.scaleToZero = scaleToZero; + } // ─── Image mode ───────────────────────────────────────────────── if (!dir) { @@ -188,8 +210,16 @@ export function registerComputeDeployCommand(computeCmd: Command): void { return; } - // ─── Source mode (Path A) ─────────────────────────────────────── + // ─── Source mode ──────────────────────────────────────────────── const absDir = resolve(dir); + // Provider capability first: when it cannot build at all, whether a + // Dockerfile exists is beside the point. + if (capabilities.sourceBuild === 'none') { + throw new CLIError( + `The ${provider ?? 'configured'} compute provider cannot build from source.\n` + + ` Build the image yourself and deploy it with --image .` + ); + } const dockerfilePath = join(absDir, 'Dockerfile'); if (!existsSync(dockerfilePath)) { throw new CLIError( @@ -199,10 +229,89 @@ export function registerComputeDeployCommand(computeCmd: Command): void { ` • Use --image to deploy a pre-built image instead` ); } - ensureFlyctlAvailable(); - if (!json) outputInfo(`Detected Dockerfile at ${dockerfilePath}`); + // ─── Source mode via context upload (self-hosted Docker) ───────── + // + // The backend owns the build here because it has the daemon: upload the + // context and it builds, tags and deploys in one call. So there is no + // deploy token to mint, no flyctl on PATH, and no follow-up PATCH. + if (capabilities.sourceBuild === 'context-upload') { + const listRes = await ossFetch('/api/compute/services'); + const found = ((await listRes.json()) as Array<{ id: string; name: string }>).find( + (s) => s.name === opts.name + ); + + let serviceId: string; + if (found) { + serviceId = found.id; + if (!json) outputInfo(`Found existing service "${opts.name}", rebuilding...`); + } else { + if (!json) outputInfo(`Creating service "${opts.name}"...`); + const prepareRes = await ossFetch('/api/compute/services/deploy', { + method: 'POST', + body: JSON.stringify(baseBody), + }); + serviceId = ((await prepareRes.json()) as { id: string }).id; + } + + if (!json) outputInfo('Packing build context...'); + const { tar, fileCount } = await packBuildContext(absDir); + if (!json) { + const mb = (tar.length / 1024 / 1024).toFixed(1); + outputInfo(`Uploading ${fileCount} file(s), ${mb} MB...`); + } + + let buildRes; + try { + buildRes = await ossFetch( + `/api/compute/services/${encodeURIComponent(serviceId)}/build`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-tar' }, + body: tar, + } + ); + } catch (buildErr) { + // Same rollback rule as the flyctl path: clean up a service this + // command created, leave an existing one running. + if (!found) { + await ossFetch(`/api/compute/services/${encodeURIComponent(serviceId)}`, { + method: 'DELETE', + }).catch(() => undefined); + if (!json) outputInfo(`Rolled back service "${opts.name}" after build failure.`); + } + throw buildErr; + } + + const built = (await buildRes.json()) as { + service?: Record; + imageTag: string; + logs?: string[]; + }; + if (json) { + outputJson(built); + } else { + for (const line of built.logs ?? []) { + console.log(` ${String(line).trimEnd()}`); + } + const svc = built.service ?? {}; + const status = String(svc.status ?? 'running'); + outputSuccess(`Service "${String(svc.name ?? opts.name)}" deployed [${status}]`); + console.log(` Image: ${built.imageTag}`); + if (svc.endpointUrl) { + console.log(` Endpoint: ${String(svc.endpointUrl)}`); + } else { + console.log(` No public endpoint — reachable on the project's internal network.`); + } + } + await reportCliUsage('cli.compute.deploy', true); + return; + } + + // ─── Source mode via flyctl remote build (Fly.io) ──────────────── + ensureFlyctlAvailable(); + // 1. Resolve service: list → find by name → /deploy if missing const listRes = await ossFetch('/api/compute/services'); const existing = ((await listRes.json()) as Array<{ diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts new file mode 100644 index 0000000..176a836 --- /dev/null +++ b/src/lib/build-context.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { packBuildContext } from './build-context.js'; + +/** Entry names in a tar, read from the 100-byte name field of each 512-byte header. */ +function tarEntryNames(tar: Buffer): string[] { + const names: string[] = []; + for (let offset = 0; offset + 512 <= tar.length; offset += 512) { + const name = tar + .subarray(offset, offset + 100) + .toString('utf8') + .replace(/\0+$/, ''); + if (!name) { + continue; + } + names.push(name); + const size = parseInt( + tar + .subarray(offset + 124, offset + 136) + .toString('utf8') + .replace(/\0+$/, '') + .trim() || '0', + 8, + ); + // Skip the file body, rounded up to the next 512-byte block. + offset += Math.ceil(size / 512) * 512; + } + return names; +} + +describe('packBuildContext', () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ctx-')); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('includes the tree by default, unlike the static-deploy bundler', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + // A Dockerfile may legitimately COPY any of these, so none may be dropped. + await fs.mkdir(path.join(dir, 'dist')); + await fs.writeFile(path.join(dir, 'dist', 'app.js'), 'x'); + await fs.mkdir(path.join(dir, 'node_modules')); + await fs.writeFile(path.join(dir, 'node_modules', 'dep.js'), 'y'); + + const { tar, fileCount } = await packBuildContext(dir); + const names = tarEntryNames(tar); + + expect(names).toContain('Dockerfile'); + expect(names).toContain('dist/app.js'); + expect(names).toContain('node_modules/dep.js'); + expect(fileCount).toBe(3); + }); + + it('honours .dockerignore, including negations', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + await fs.writeFile( + path.join(dir, '.dockerignore'), + 'secrets/\n*.log\n!keep.log\n', + ); + await fs.mkdir(path.join(dir, 'secrets')); + await fs.writeFile(path.join(dir, 'secrets', 'key.pem'), 'private'); + await fs.writeFile(path.join(dir, 'debug.log'), 'noise'); + await fs.writeFile(path.join(dir, 'keep.log'), 'wanted'); + + const names = tarEntryNames((await packBuildContext(dir)).tar); + + expect(names).not.toContain('secrets/key.pem'); + expect(names).not.toContain('debug.log'); + expect(names).toContain('keep.log'); + expect(names).toContain('Dockerfile'); + }); + + // Usually the largest thing in the tree, almost never needed by a build, and it + // carries every secret ever committed. + it('always excludes .git', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + await fs.mkdir(path.join(dir, '.git', 'objects'), { recursive: true }); + await fs.writeFile(path.join(dir, '.git', 'config'), '[core]'); + await fs.writeFile(path.join(dir, '.git', 'objects', 'blob'), 'data'); + + const names = tarEntryNames((await packBuildContext(dir)).tar); + + expect(names.some((n) => n.startsWith('.git'))).toBe(false); + expect(names).toContain('Dockerfile'); + }); + + it('produces a tar the daemon can read (512-byte blocks, name in the header)', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + const { tar } = await packBuildContext(dir); + + expect(tar.length % 512).toBe(0); + expect(tar.subarray(0, 10).toString('utf8')).toContain('Dockerfile'); + }); +}); diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts new file mode 100644 index 0000000..d67dd32 --- /dev/null +++ b/src/lib/build-context.ts @@ -0,0 +1,87 @@ +// Pack a directory into the tar that POST /api/compute/services/:id/build expects. +// +// Deliberately *not* modelled on the deployments bundler, which strips +// node_modules, dist, build and friends. Those exclusions are right for a static +// site and wrong for a Docker build: a Dockerfile may legitimately COPY any of +// them, and silently dropping one produces a build that fails for a reason nothing +// on screen explains. Docker's own contract is 'everything, minus .dockerignore', +// so that is what this does. When the context is too big the backend answers 413 +// and names .dockerignore as the fix. + +import archiver from 'archiver'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import ignore from 'ignore'; + +export interface BuildContext { + tar: Buffer; + fileCount: number; +} + +/** `.dockerignore` matcher, or null when the file is absent. */ +async function loadDockerignore(dir: string) { + try { + const raw = await fs.readFile(path.join(dir, '.dockerignore'), 'utf8'); + // Docker's .dockerignore is gitignore-ish; `ignore` covers the syntax that + // matters here. `!` negations and `**` both work. + return ignore().add(raw); + } catch { + return null; + } +} + +/** + * Tar `dir` for upload. + * + * Built in memory because the backend accepts one buffered body anyway, and the + * ceiling it enforces (64MB by default) is well under what a CLI can hold. + */ +export async function packBuildContext(dir: string): Promise { + const matcher = await loadDockerignore(dir); + const archive = archiver('tar'); + const chunks: Buffer[] = []; + archive.on('data', (c: Buffer) => chunks.push(c)); + + const done = new Promise((resolve, reject) => { + archive.on('end', resolve); + archive.on('error', reject); + }); + + let fileCount = 0; + async function walk(current: string): Promise { + const entries = await fs.readdir(current, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = path.relative(dir, absolute).split(path.sep).join('/'); + if (!relative) { + continue; + } + // `.git` is the one thing excluded unconditionally: it is often the largest + // thing in the tree, a Dockerfile that needs it is vanishingly rare, and + // shipping it means shipping every secret ever committed. + if (relative === '.git' || relative.startsWith('.git/')) { + continue; + } + if (matcher?.ignores(entry.isDirectory() ? `${relative}/` : relative)) { + continue; + } + if (entry.isDirectory()) { + await walk(absolute); + continue; + } + if (!entry.isFile()) { + // Sockets, fifos and dangling symlinks have no meaning in a build context. + continue; + } + archive.file(absolute, { name: relative }); + fileCount++; + } + } + + await walk(dir); + void archive.finalize(); + await done; + + return { tar: Buffer.concat(chunks), fileCount }; +} diff --git a/src/lib/compute-capabilities.ts b/src/lib/compute-capabilities.ts new file mode 100644 index 0000000..d2913df --- /dev/null +++ b/src/lib/compute-capabilities.ts @@ -0,0 +1,74 @@ +// What the backend's configured compute provider can actually do. +// +// Compute is no longer Fly-only: a self-hoster can run containers on their own +// Docker daemon instead, where there are no regions, no scale-to-zero, and source +// builds happen by uploading a context to the backend rather than by this CLI +// shelling out to flyctl. Sending a Fly-shaped request to that backend does not +// fail loudly — the region is simply recorded and ignored — so the CLI asks first. +// +// Reported as the `compute` slice of /api/metadata. The slice is absent on a +// backend older than it and when no driver is configured, so every field has to +// degrade to the Fly-shaped behaviour this CLI has always had. + +import { ossFetch } from './api/oss.js'; + +export interface ComputeCapabilities { + scaleToZero: boolean; + regions: boolean; + ingressModes: string[]; + sourceBuild: 'none' | 'flyctl' | 'context-upload'; + deployTokenIssuance: boolean; +} + +/** Assumed shape for a backend that does not report capabilities: Fly. */ +const LEGACY_FLY: ComputeCapabilities = { + scaleToZero: true, + regions: true, + ingressModes: ['host'], + sourceBuild: 'flyctl', + deployTokenIssuance: true, +}; + +interface MetadataWithCompute { + compute?: { + defaultProvider?: string; + providers?: Record>; + }; +} + +/** + * Capabilities of the provider new services go to, plus its name. + * + * Never throws: a CLI that cannot deploy because a capability probe failed is + * worse than one that tries the way it always has. `provider` is null when the + * backend reported nothing, which callers can use to explain a fallback. + */ +export async function fetchComputeCapabilities(): Promise<{ + provider: string | null; + capabilities: ComputeCapabilities; +}> { + try { + const res = await ossFetch('/api/metadata'); + const meta = (await res.json()) as MetadataWithCompute; + const provider = meta.compute?.defaultProvider; + const reported = provider ? meta.compute?.providers?.[provider] : undefined; + if (!provider || !reported) { + return { provider: null, capabilities: LEGACY_FLY }; + } + // Field by field, so a backend that grows a capability this CLI does not know + // about — or omits one it does — still yields a complete object. + return { + provider, + capabilities: { + scaleToZero: reported.scaleToZero ?? LEGACY_FLY.scaleToZero, + regions: reported.regions ?? LEGACY_FLY.regions, + ingressModes: reported.ingressModes ?? LEGACY_FLY.ingressModes, + sourceBuild: reported.sourceBuild ?? LEGACY_FLY.sourceBuild, + deployTokenIssuance: + reported.deployTokenIssuance ?? LEGACY_FLY.deployTokenIssuance, + }, + }; + } catch { + return { provider: null, capabilities: LEGACY_FLY }; + } +}