-
Notifications
You must be signed in to change notification settings - Fork 18
feat(compute): support the self-hosted Docker provider #227
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -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 <mb>', 'Memory in MB', '512') | ||
| .option('--region <region>', 'Fly.io region', 'iad') | ||
| .option('--region <region>', 'Region (providers that have more than one)', 'iad') | ||
| .option('--env <json>', 'Env vars as JSON object') | ||
| .option( | ||
| '--env-file <path>', | ||
|
|
@@ -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<string, unknown> = { | ||
| 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 <url>.` | ||
| ); | ||
| } | ||
| const dockerfilePath = join(absDir, 'Dockerfile'); | ||
| if (!existsSync(dockerfilePath)) { | ||
| throw new CLIError( | ||
|
|
@@ -199,10 +229,89 @@ export function registerComputeDeployCommand(computeCmd: Command): void { | |
| ` • Use --image <url> 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; | ||
|
Contributor
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. P1: Redeploying an existing Docker service ignores deploy settings such as Prompt for AI agents |
||
| if (!json) outputInfo(`Found existing service "${opts.name}", rebuilding...`); | ||
|
Comment on lines
+246
to
+248
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.
When source-redeploying an existing Docker-backed service with changed port, CPU, memory, environment, or protocol options, this branch retains only the service ID and the subsequent request sends only the tar archive. The new image is deployed while the service silently keeps its previous configuration, which can leave the container unreachable or incorrectly configured. Knowledge Base Used: Compute & Deployments |
||
| } 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); | ||
|
Contributor
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 filesystem/archiving failure after creating a new service skips rollback and leaves the prepared service behind. Include context packing in the rollback-protected operation so any source-build failure deletes a service created by this command. Prompt for AI agents |
||
| 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<string, unknown>; | ||
| 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); | ||
|
Contributor
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: The new context-upload source-build path ends with Prompt for AI agents |
||
| 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<{ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Detecting an explicitly-supplied region via
process.argv.includes('--region')misses the--region=<value>syntax that commander accepts. When a user writes--region=lhragainst a single-host provider, no 'Ignoring --region' message is printed yet the region is still silently omitted from the request body (becausecapabilities.regionsis false) — the exact situation the message exists to surface. Make the detection cover the=-form too (e.g. match/^--region(=|$)/against argv), so the informative message fires regardless of flag spelling.Prompt for AI agents