From 19039f27f3d914cde8a210c75df3ec29d66fc391 Mon Sep 17 00:00:00 2001 From: abrulic Date: Sat, 25 Jul 2026 16:56:05 +0200 Subject: [PATCH] update with fly memory setup --- README.md | 19 +++++++++++ examples/apps/marketing/Dockerfile | 2 +- examples/apps/marketing/fly.toml | 15 +++++++- examples/apps/web/Dockerfile | 2 +- examples/apps/web/fly.toml | 13 +++++++ examples/deploykit.config.ts | 10 ++++-- src/config.ts | 22 ++++++++++++ src/generate/dockerfile-nx.test.ts | 2 +- src/generate/dockerfile-nx.ts | 4 +-- src/generate/dockerfile-shared.ts | 45 ++++++++++++++++++++++-- src/generate/dockerfile-turbo.ts | 4 +-- src/generate/dockerfile.test.ts | 29 +++++++++++++--- src/generate/flytoml.test.ts | 55 ++++++++++++++++++++++++++++++ src/generate/flytoml.ts | 54 +++++++++++++++++++++++++++-- src/prompts.test.ts | 17 +++++++++ src/prompts.ts | 43 +++++++++++++++++++++++ src/vm.test.ts | 28 +++++++++++++++ src/vm.ts | 14 ++++++++ 18 files changed, 358 insertions(+), 20 deletions(-) create mode 100644 src/vm.test.ts create mode 100644 src/vm.ts diff --git a/README.md b/README.md index c4a3fba..a19522a 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ export default defineConfig({ "serve": "server", "port": 3000, "healthCheckPath": "/", + "vm": { "memory": "512mb" }, // asked at init; edit to resize the VM "internalDeps": ["@acme/ui", "@acme/database"], "secrets": ["DATABASE_URL", "SESSION_SECRET"], // runtime env vars "buildEnv": ["VITE_API_URL"], // baked in at build time @@ -226,6 +227,24 @@ Every generated `fly.toml` includes an HTTP health check: Fly waits for this check to pass before shifting traffic to a new release, and **keeps the old machines running if it fails** — so a bad deploy rolls itself back. If your app's `/` returns a 404 (e.g. an API with no root route), set `healthCheckPath` for that app in `deploykit.config.ts` to a lightweight endpoint like `/health`, or the deploy would wedge. +## VM sizing, concurrency & build cache + +deploykit shapes these per app from the workload rather than hardcoding one size for everything: + +- **VM memory** — because memory maps directly to cost and to whether an app OOMs, `deploykit init` **asks per app**, pre-filled with a workload-aware suggestion (static `256mb`, Next SSR `1024mb` — Next commonly OOMs at 512mb — other servers `512mb`). Hit Enter to accept, or type another value like `2gb`. Your choice is recorded in the config, so regeneration never resizes the VM under you. `--yes` takes the suggestion for every app; CPU stays `shared-cpu-1x`. +- **Concurrency** — the thresholds at which Fly load-balances to and cold-starts another machine. Fly's own default is a low `20/25`; deploykit emits a workload-aware `[http_service.concurrency]` instead — servers cap by `requests` (SSR is CPU-bound), static apps by `connections` (cheap to serve many). +- **Build cache** — every generated Dockerfile mounts a per-manager BuildKit cache on the install layer (`RUN --mount=type=cache …`), so re-installs reuse already-downloaded packages across builds. The `# syntax=docker/dockerfile:1` directive that enables it is always emitted; without BuildKit the mount is ignored, not an error. + +These are starting points — the right numbers come from real traffic. Override memory, CPU or concurrency per app in `deploykit.config.ts` (regeneration keeps your values): + +```ts +"web": { + // … + "vm": { "size": "shared-cpu-2x", "memory": "2048mb" }, + "concurrency": { "type": "requests", "softLimit": 300, "hardLimit": 400 } +} +``` + ## Rolling back a release When a release deployed cleanly but turned out bad, redeploy a previous image: diff --git a/examples/apps/marketing/Dockerfile b/examples/apps/marketing/Dockerfile index 26a99c2..bab8044 100644 --- a/examples/apps/marketing/Dockerfile +++ b/examples/apps/marketing/Dockerfile @@ -18,7 +18,7 @@ FROM base AS build WORKDIR /app # Lockfile + package manifests first for a cacheable install layer. COPY --from=prune /app/out/json/ . -RUN pnpm install --frozen-lockfile +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile # Then the pruned source, and build. COPY --from=prune /app/out/full/ . RUN pnpm turbo run build --filter=@acme/marketing diff --git a/examples/apps/marketing/fly.toml b/examples/apps/marketing/fly.toml index 21f9ad1..c86946b 100644 --- a/examples/apps/marketing/fly.toml +++ b/examples/apps/marketing/fly.toml @@ -13,6 +13,16 @@ primary_region = "iad" min_machines_running = 0 processes = ["app"] + # Concurrency: the thresholds at which Fly load-balances to and cold-starts + # another machine. Fly's default is a low 20/25; this baseline is workload- + # aware — a server caps by requests (SSR is CPU-bound), a static app by + # connections (cheap to serve many). Tune to real traffic, or override via + # `concurrency` in deploykit.config.ts. + [http_service.concurrency] + type = "connections" + soft_limit = 400 + hard_limit = 500 + # Health check: Fly waits for this to pass before shifting traffic to a new # release, and keeps the old machines running if it fails — auto-rollback on a # bad deploy. If "/" 404s (e.g. an API with no root route), point @@ -26,6 +36,9 @@ primary_region = "iad" timeout = "5s" grace_period = "30s" +# VM: workload-aware default (static 256mb · Next SSR 1024mb · other servers +# 512mb, all shared-cpu-1x). Override with `vm` in deploykit.config.ts so +# `deploykit generate` keeps your sizing. [[vm]] size = "shared-cpu-1x" - memory = "512mb" + memory = "256mb" diff --git a/examples/apps/web/Dockerfile b/examples/apps/web/Dockerfile index 8a3a7ad..dbeb178 100644 --- a/examples/apps/web/Dockerfile +++ b/examples/apps/web/Dockerfile @@ -18,7 +18,7 @@ FROM base AS build WORKDIR /app # Lockfile + package manifests first for a cacheable install layer. COPY --from=prune /app/out/json/ . -RUN pnpm install --frozen-lockfile +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile # Then the pruned source, and build. COPY --from=prune /app/out/full/ . ARG VITE_API_URL diff --git a/examples/apps/web/fly.toml b/examples/apps/web/fly.toml index 73c9112..c35011c 100644 --- a/examples/apps/web/fly.toml +++ b/examples/apps/web/fly.toml @@ -23,6 +23,16 @@ primary_region = "iad" min_machines_running = 0 processes = ["app"] + # Concurrency: the thresholds at which Fly load-balances to and cold-starts + # another machine. Fly's default is a low 20/25; this baseline is workload- + # aware — a server caps by requests (SSR is CPU-bound), a static app by + # connections (cheap to serve many). Tune to real traffic, or override via + # `concurrency` in deploykit.config.ts. + [http_service.concurrency] + type = "requests" + soft_limit = 200 + hard_limit = 250 + # Health check: Fly waits for this to pass before shifting traffic to a new # release, and keeps the old machines running if it fails — auto-rollback on a # bad deploy. If "/" 404s (e.g. an API with no root route), point @@ -36,6 +46,9 @@ primary_region = "iad" timeout = "5s" grace_period = "30s" +# VM: workload-aware default (static 256mb · Next SSR 1024mb · other servers +# 512mb, all shared-cpu-1x). Override with `vm` in deploykit.config.ts so +# `deploykit generate` keeps your sizing. [[vm]] size = "shared-cpu-1x" memory = "512mb" diff --git a/examples/deploykit.config.ts b/examples/deploykit.config.ts index fe98301..c223ba4 100644 --- a/examples/deploykit.config.ts +++ b/examples/deploykit.config.ts @@ -67,7 +67,10 @@ export default defineConfig({ ], "buildEnv": [ "VITE_API_URL" - ] + ], + "vm": { + "memory": "512mb" + } }, "marketing": { "root": "apps/marketing", @@ -93,7 +96,10 @@ export default defineConfig({ } }, "secrets": [], - "outputDir": "dist" + "outputDir": "dist", + "vm": { + "memory": "256mb" + } } }, "namePrefix": "acme", diff --git a/src/config.ts b/src/config.ts index 060ced1..0509f2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,9 @@ export type Framework = /** How the runner stage serves the app. Decoupled from `Framework`. */ export type ServeModel = "static" | "server"; +/** How Fly counts load for autoscaling / load-balancing (`http_service.concurrency`). */ +export type ConcurrencyType = "requests" | "connections"; + /** * A workspace package that ships a Prisma schema. Its client isn't generated on * install under pnpm 10 / Prisma 7, so the Dockerfile runs `prisma generate` @@ -157,6 +160,25 @@ export interface AppConfig { * (e.g. "/health") for an API whose "/" 404s, or it would wedge the deploy. */ healthCheckPath?: string; + /** + * Override the runner VM. Omitted → a workload-aware default in fly.toml, all + * on shared-cpu-1x: static apps get 256mb, a Next SSR server 1024mb (Next + * commonly OOMs at 512mb), other servers 512mb. Set either field to pin it so + * `deploykit generate` keeps your sizing. + */ + vm?: { size?: string; memory?: string }; + /** + * Override Fly's `http_service.concurrency` — the thresholds at which Fly + * load-balances to and cold-starts another machine. Omitted → a workload-aware + * default: servers cap by "requests" (200/250, SSR is CPU-bound), static apps + * by "connections" (400/500, cheap to serve many). Fly's own default is a low + * connections 20/25. + */ + concurrency?: { + type?: ConcurrencyType; + softLimit?: number; + hardLimit?: number; + }; /** Names of internal workspace packages this app depends on. */ internalDeps: string[]; /** diff --git a/src/generate/dockerfile-nx.test.ts b/src/generate/dockerfile-nx.test.ts index be39cbe..41cf4db 100644 --- a/src/generate/dockerfile-nx.test.ts +++ b/src/generate/dockerfile-nx.test.ts @@ -102,7 +102,7 @@ describe("generateDockerfile (Nx, package-based / server model)", () => { config: { ...pkgBasedConfig, installEnv: { LEFTHOOK: "0" } }, }); expect(withEnv).toContain( - "RUN pnpm install --frozen-lockfile --ignore-scripts", + "RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --ignore-scripts", ); expect(withEnv).toContain( 'RUN cd packages/db && DATABASE_URL="postgresql://build:build@localhost:5432/build" pnpm exec prisma generate', diff --git a/src/generate/dockerfile-nx.ts b/src/generate/dockerfile-nx.ts index fd1af3f..d01ce4c 100644 --- a/src/generate/dockerfile-nx.ts +++ b/src/generate/dockerfile-nx.ts @@ -3,7 +3,7 @@ import { baseStage, buildEnvLines, fileHeader, - installLine, + installStep, nodeImage, PM, prismaSteps, @@ -32,7 +32,7 @@ export function nxDockerfile({ app, config }: GenerateAppFileInput) { FROM base AS build WORKDIR /app COPY . . -RUN ${installLine(pm, config)} +${installStep({ pm, config })} ${buildEnvLines(app)}${prismaSteps(app, pm)}RUN ${pm.run} nx build ${project}${productionFlag} `; diff --git a/src/generate/dockerfile-shared.ts b/src/generate/dockerfile-shared.ts index 03654b1..dbf6fe9 100644 --- a/src/generate/dockerfile-shared.ts +++ b/src/generate/dockerfile-shared.ts @@ -14,6 +14,18 @@ export interface PmCommands { dlx: string; /** Frozen install from the lockfile. */ install: string; + /** + * BuildKit cache mount for the install layer — the manager's package + * store/cache dir, persisted across builds so re-installs skip re-downloading. + * `id` namespaces the cache; `dir` is the mount target. `flag`, when set, pins + * the manager's cache to `dir` (yarn/bun default it elsewhere); pnpm's store + * already lands in `/pnpm` (PNPM_HOME) and npm caches in `/root/.npm`, so they + * need none. The `# syntax` directive enables the mount; without BuildKit it's + * ignored, not an error. + */ + cacheId: string; + cacheDir: string; + cacheFlag?: string; } export const PM: Record = { @@ -22,24 +34,33 @@ export const PM: Record = { exec: "pnpm exec", dlx: "pnpm dlx", install: "pnpm install --frozen-lockfile", + cacheId: "pnpm", + cacheDir: "/pnpm/store", }, npm: { run: "npx", exec: "npx", dlx: "npx --yes", install: "npm ci", + cacheId: "npm", + cacheDir: "/root/.npm", }, yarn: { run: "yarn", exec: "yarn exec", dlx: "yarn dlx", install: "yarn install --frozen-lockfile", + cacheId: "yarn", + cacheDir: "/root/.yarn-cache", + cacheFlag: "--cache-folder /root/.yarn-cache", }, bun: { run: "bunx", exec: "bunx", dlx: "bunx", install: "bun install", + cacheId: "bun", + cacheDir: "/root/.bun/install/cache", }, }; @@ -47,7 +68,9 @@ export const PM: Record = { * How the runner serves an app. Prefers the detected `serve` field; falls back * to the framework for configs generated before that field existed. */ -export const serveModel = (app: AppConfig): ServeModel => +export const serveModel = ( + app: Pick, +): ServeModel => app.serve ?? (app.framework === "next" || app.framework === "remix" || @@ -67,7 +90,7 @@ export const serveModel = (app: AppConfig): ServeModel => * install instead (`--ignore-scripts`). Prisma generate and the build run as * their own steps, so nothing needed at build time depends on those scripts. */ -export const installLine = (pm: PmCommands, config: DeploykitConfig) => { +const installLine = (pm: PmCommands, config: DeploykitConfig) => { const env = { ...config.installEnv }; const ignoreScripts = "LEFTHOOK" in env; delete env.LEFTHOOK; // no-op at install time — dropped in favor of --ignore-scripts @@ -76,9 +99,25 @@ export const installLine = (pm: PmCommands, config: DeploykitConfig) => { .map(([k, v]) => `${k}=${v}`) .join(" ")} ` : ""; - return `${prefix}${pm.install}${ignoreScripts ? " --ignore-scripts" : ""}`; + const cacheFlag = pm.cacheFlag ? ` ${pm.cacheFlag}` : ""; + return `${prefix}${pm.install}${cacheFlag}${ignoreScripts ? " --ignore-scripts" : ""}`; }; +/** + * The workspace install as a `RUN` with a BuildKit cache mount on the package + * manager's store/cache, so re-installs reuse already-downloaded packages across + * builds — the biggest build-speed lever in a monorepo. The mount needs BuildKit + * (enabled by the Dockerfile's `# syntax` directive) and is ignored otherwise. + */ +export const installStep = ({ + pm, + config, +}: { + pm: PmCommands; + config: DeploykitConfig; +}) => + `RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheDir} ${installLine(pm, config)}`; + /** * ARG/ENV lines for the app's build-time vars (NEXT_PUBLIC_*, VITE_*, and all * vars of a static app). Declared in the build stage so `--build-arg` values diff --git a/src/generate/dockerfile-turbo.ts b/src/generate/dockerfile-turbo.ts index d28519a..d4f65ae 100644 --- a/src/generate/dockerfile-turbo.ts +++ b/src/generate/dockerfile-turbo.ts @@ -3,7 +3,7 @@ import { baseStage, buildEnvLines, fileHeader, - installLine, + installStep, nodeImage, PM, prismaSteps, @@ -34,7 +34,7 @@ FROM base AS build WORKDIR /app # Lockfile + package manifests first for a cacheable install layer. COPY --from=prune /app/out/json/ . -RUN ${installLine(pm, config)} +${installStep({ pm, config })} # Then the pruned source, and build. COPY --from=prune /app/out/full/ . ${buildEnvLines(app)}${prismaSteps(app, pm)}RUN ${pm.run} turbo run build --filter=${filter} diff --git a/src/generate/dockerfile.test.ts b/src/generate/dockerfile.test.ts index 0f7e3fc..141205a 100644 --- a/src/generate/dockerfile.test.ts +++ b/src/generate/dockerfile.test.ts @@ -30,7 +30,22 @@ describe("generateDockerfile", () => { }; expect(gen("next", npmConfig)).toContain("npx --yes turbo prune"); // Frozen install from the lockfile — `npm install` may rewrite it. - expect(gen("next", npmConfig)).toContain("RUN npm ci"); + expect(gen("next", npmConfig)).toContain( + "RUN --mount=type=cache,id=npm,target=/root/.npm npm ci", + ); + }); + + it("mounts a per-manager BuildKit cache on the install layer", () => { + // The `# syntax` directive that enables the mount is always emitted. + expect(gen("next")).toContain("# syntax=docker/dockerfile:1"); + expect(gen("next")).toContain( + "RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile", + ); + const yarn = gen("next", { ...sampleConfig, packageManager: "yarn" }); + // yarn defaults its cache elsewhere, so the target is pinned with a flag. + expect(yarn).toContain( + "RUN --mount=type=cache,id=yarn,target=/root/.yarn-cache yarn install --frozen-lockfile --cache-folder /root/.yarn-cache", + ); }); it("installs bun in the base stage (corepack doesn't provide it)", () => { @@ -40,10 +55,12 @@ describe("generateDockerfile", () => { }; const out = gen("next", bunConfig); expect(out).toContain("RUN npm install -g bun"); - expect(out).toContain("RUN bun install"); + expect(out).toContain( + "RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache bun install", + ); // bun must be installed before the workspace install runs. expect(out.indexOf("npm install -g bun")).toBeLessThan( - out.indexOf("RUN bun install"), + out.indexOf("bun install"), ); }); @@ -94,7 +111,7 @@ describe("generateDockerfile", () => { // Lefthook's `prepare` hook can't be neutralized by LEFTHOOK=0 (it shells // out to git first), so lifecycle scripts are skipped on the install. expect(out).toContain( - "RUN pnpm install --frozen-lockfile --ignore-scripts", + "RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --ignore-scripts", ); // No prisma.config → the --schema flag is present. expect(out).toContain( @@ -116,7 +133,9 @@ describe("generateDockerfile", () => { app: appWith("node-server"), config, }); - expect(out).toContain("RUN HUSKY=0 pnpm install --frozen-lockfile"); + expect(out).toContain( + "RUN --mount=type=cache,id=pnpm,target=/pnpm/store HUSKY=0 pnpm install --frozen-lockfile", + ); expect(out).not.toContain("--ignore-scripts"); }); diff --git a/src/generate/flytoml.test.ts b/src/generate/flytoml.test.ts index 6c437fd..2571c61 100644 --- a/src/generate/flytoml.test.ts +++ b/src/generate/flytoml.test.ts @@ -92,4 +92,59 @@ describe("generateFlyToml", () => { expect(withCfg).toContain("migrate deploy)"); expect(withCfg).not.toContain("--schema"); }); + + it("sizes the VM by workload: Next SSR gets more memory than a static app", () => { + // sampleWebApp is Next (server) → 1024mb, since Next commonly OOMs at 512. + expect(toml).toContain('size = "shared-cpu-1x"'); + expect(toml).toContain('memory = "1024mb"'); + + const staticApp = generateFlyToml({ + name: "site", + app: { ...sampleWebApp, framework: "astro", serve: "static" }, + config: sampleConfig, + }); + expect(staticApp).toContain('memory = "256mb"'); + + const nodeServer = generateFlyToml({ + name: "api", + app: { ...sampleWebApp, framework: "node-server" }, + config: sampleConfig, + }); + expect(nodeServer).toContain('memory = "512mb"'); + }); + + it("emits a workload-aware http_service.concurrency block", () => { + // A server caps by requests; a static app by connections. + expect(toml).toContain("[http_service.concurrency]"); + expect(toml).toContain('type = "requests"'); + expect(toml).toContain("soft_limit = 200"); + expect(toml).toContain("hard_limit = 250"); + + const staticApp = generateFlyToml({ + name: "site", + app: { ...sampleWebApp, framework: "astro", serve: "static" }, + config: sampleConfig, + }); + expect(staticApp).toContain('type = "connections"'); + expect(staticApp).toContain("soft_limit = 400"); + expect(staticApp).toContain("hard_limit = 500"); + }); + + it("lets the config override VM size and concurrency", () => { + const custom = generateFlyToml({ + name: "web", + app: { + ...sampleWebApp, + vm: { size: "performance-2x", memory: "4096mb" }, + concurrency: { type: "connections", softLimit: 1000, hardLimit: 1200 }, + }, + config: sampleConfig, + }); + expect(custom).toContain('size = "performance-2x"'); + expect(custom).toContain('memory = "4096mb"'); + expect(custom).not.toContain('memory = "1024mb"'); + expect(custom).toContain('type = "connections"'); + expect(custom).toContain("soft_limit = 1000"); + expect(custom).toContain("hard_limit = 1200"); + }); }); diff --git a/src/generate/flytoml.ts b/src/generate/flytoml.ts index 7f84473..0a3a655 100644 --- a/src/generate/flytoml.ts +++ b/src/generate/flytoml.ts @@ -1,6 +1,41 @@ import type { AppConfig } from "../config.js"; +import { defaultMemory } from "../vm.js"; +import { serveModel } from "./dockerfile-shared.js"; import type { GenerateAppFileInput } from "./types.js"; +/** + * The VM block. Memory is the per-app choice `init` records (falling back to the + * shared workload-aware default for older configs); CPU stays shared-cpu-1x — + * bumping it is a cost decision better made against real load. Both overridable + * via `vm` in the config. + */ +function vmSize(app: AppConfig) { + return { + size: app.vm?.size ?? "shared-cpu-1x", + memory: app.vm?.memory ?? defaultMemory(app), + }; +} + +/** + * Workload-aware concurrency default (overridable via `concurrency`). Fly's own + * default is a low connections 20/25; a server caps by "requests" (SSR is + * CPU-bound per request), a static app by "connections" (serving files is cheap, + * so tolerate many). These are starting points — the right numbers come from + * watching real traffic. + */ +function concurrencyLimits(app: AppConfig) { + const base = + serveModel(app) === "static" + ? { type: "connections", soft: 400, hard: 500 } + : { type: "requests", soft: 200, hard: 250 }; + const c = app.concurrency; + return { + type: c?.type ?? base.type, + soft: c?.softLimit ?? base.soft, + hard: c?.hardLimit ?? base.hard, + }; +} + /** * A COMMENTED-OUT `[deploy] release_command` for apps with a detected Prisma * schema — Fly's idiomatic per-release migration hook. Emitted only as guidance: @@ -36,6 +71,8 @@ function migrationHint(app: AppConfig): string { */ export function generateFlyToml({ name, app, config }: GenerateAppFileInput) { const healthPath = app.healthCheckPath ?? "/"; + const vm = vmSize(app); + const cc = concurrencyLimits(app); return `# Generated by deploykit — safe to edit and commit. # CI overrides the app name per environment via \`flyctl deploy --app \`. app = "${name}" @@ -51,6 +88,16 @@ ${migrationHint(app)}[http_service] min_machines_running = 0 processes = ["app"] + # Concurrency: the thresholds at which Fly load-balances to and cold-starts + # another machine. Fly's default is a low 20/25; this baseline is workload- + # aware — a server caps by requests (SSR is CPU-bound), a static app by + # connections (cheap to serve many). Tune to real traffic, or override via + # \`concurrency\` in deploykit.config.ts. + [http_service.concurrency] + type = "${cc.type}" + soft_limit = ${cc.soft} + hard_limit = ${cc.hard} + # Health check: Fly waits for this to pass before shifting traffic to a new # release, and keeps the old machines running if it fails — auto-rollback on a # bad deploy. If "${healthPath}" 404s (e.g. an API with no root route), point @@ -64,8 +111,11 @@ ${migrationHint(app)}[http_service] timeout = "5s" grace_period = "30s" +# VM: workload-aware default (static 256mb · Next SSR 1024mb · other servers +# 512mb, all shared-cpu-1x). Override with \`vm\` in deploykit.config.ts so +# \`deploykit generate\` keeps your sizing. [[vm]] - size = "shared-cpu-1x" - memory = "512mb" + size = "${vm.size}" + memory = "${vm.memory}" `; } diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 60dd60e..47a85d1 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -171,6 +171,23 @@ describe("buildConfig (non-interactive)", () => { expect(config?.apps.web?.secrets).toEqual(["DATABASE_URL"]); expect(config?.apps.web?.buildEnv).toEqual(["NEXT_PUBLIC_API_URL"]); }); + + it("records workload-aware VM memory per app (--yes takes the suggestion)", async () => { + // web is Next/server → 1024mb; a static app → 256mb. + const withStatic: Detection = { + ...detection, + apps: [ + app, + { ...app, name: "site", framework: "astro", serve: "static" }, + ], + }; + const config = await buildConfig({ + detection: withStatic, + opts: { ...baseOpts, org: "acme" }, + }); + expect(config?.apps.web?.vm).toEqual({ memory: "1024mb" }); + expect(config?.apps.site?.vm).toEqual({ memory: "256mb" }); + }); }); describe("name prefix derivation", () => { diff --git a/src/prompts.ts b/src/prompts.ts index 2bb8a24..c09fc40 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -19,6 +19,7 @@ import { listFlyOrgs } from "./fly.js"; import { readCredential, saveCredential } from "./secrets-file.js"; import { readJson } from "./util/fsx.js"; import { link, pc } from "./util/log.js"; +import { defaultMemory } from "./vm.js"; /** Custom hostnames keyed by app name → environment. */ type HostMap = Record>>; @@ -96,6 +97,9 @@ export async function buildConfig({ const chosenApps = await pickApps(deployable); if (!chosenApps) return cancel(); + const memory = await pickMemory(chosenApps); + if (!memory) return cancel(); + // --envs picks the environments up front; otherwise ask. const envs = opts.envs ?? (await pickEnvironments()); if (!envs) return cancel(); @@ -119,6 +123,7 @@ export async function buildConfig({ namePrefix, cloudflare: cf.cloudflare, hostnames: cf.hostnames, + memory, }); } @@ -209,6 +214,34 @@ async function pickApps(deployable: DetectedApp[]) { return deployable.filter((a) => choice.includes(a.name)); } +/** + * Fly VM memory per app, pre-filled with the workload-aware suggestion — hit + * Enter to accept it, or type another value (e.g. "2gb"). Memory maps directly + * to cost and to whether an app OOMs, so it's an explicit choice rather than a + * silent default; the CPU stays shared-cpu-1x. Returns a name→memory map, or + * null on cancel. (`--yes` skips this and takes the suggestion for every app.) + */ +async function pickMemory( + apps: DetectedApp[], +): Promise | null> { + const memory: Record = {}; + for (const app of apps) { + const suggestion = defaultMemory(app); + const input = await p.text({ + message: `Memory for ${pc.bold(app.name)} ${pc.dim(`(${app.framework} · ${app.serve})`)}`, + initialValue: suggestion, + placeholder: suggestion, + validate: (v) => + /^\d+(mb|gb)$/i.test(v.trim()) + ? undefined + : 'A Fly memory size like "512mb", "1024mb" or "2gb".', + }); + if (p.isCancel(input)) return null; + memory[app.name] = input.trim().toLowerCase(); + } + return memory; +} + async function pickEnvironments() { // No pre-selection: the chosen set is exactly what the user checks, so // highlighting "Staging" and hitting enter yields staging only. @@ -505,6 +538,7 @@ function assemble({ namePrefix = "", cloudflare, hostnames, + memory, }: { detection: Detection; apps: DetectedApp[]; @@ -514,6 +548,8 @@ function assemble({ namePrefix?: string; cloudflare?: CloudflareConfig; hostnames?: HostMap; + /** Chosen VM memory per app name; unset entries take the workload default. */ + memory?: Record; }) { const appMap: Record = {}; for (const a of apps) @@ -522,6 +558,7 @@ function assemble({ envs, namePrefix, hosts: hostnames?.[a.name], + memory: memory?.[a.name], }); const providerConfig: ProviderConfig = { @@ -554,11 +591,14 @@ function appConfigFor({ envs, namePrefix, hosts, + memory, }: { app: DetectedApp; envs: EnvironmentKind[]; namePrefix: string; hosts?: Partial>; + /** Chosen VM memory; unset → the workload-aware default. */ + memory?: string; }) { // Base of every Fly app name for this app, e.g. "acme-shop-web". const base = namePrefix ? `${namePrefix}-${app.name}` : app.name; @@ -594,6 +634,9 @@ function appConfigFor({ if (app.spa) config.spa = true; if (app.prisma?.length) config.prisma = app.prisma; if (app.buildEnv.length) config.buildEnv = app.buildEnv; + // Memory is always recorded (an explicit choice, not a silent default), so + // regenerating never surprises the user with a resized VM. + config.vm = { memory: memory ?? defaultMemory(app) }; return config; } diff --git a/src/vm.test.ts b/src/vm.test.ts new file mode 100644 index 0000000..036508f --- /dev/null +++ b/src/vm.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { defaultMemory } from "./vm.js"; + +describe("defaultMemory", () => { + it("gives a static app the least memory", () => { + expect(defaultMemory({ framework: "astro", serve: "static" })).toBe( + "256mb", + ); + // serve falls back to the framework when unset (older configs). + expect(defaultMemory({ framework: "vite" })).toBe("256mb"); + }); + + it("gives a Next SSR server headroom (it OOMs at 512mb)", () => { + expect(defaultMemory({ framework: "next", serve: "server" })).toBe( + "1024mb", + ); + expect(defaultMemory({ framework: "next" })).toBe("1024mb"); + }); + + it("gives other servers the middle default", () => { + expect(defaultMemory({ framework: "node-server", serve: "server" })).toBe( + "512mb", + ); + expect(defaultMemory({ framework: "react-router", serve: "server" })).toBe( + "512mb", + ); + }); +}); diff --git a/src/vm.ts b/src/vm.ts new file mode 100644 index 0000000..7392152 --- /dev/null +++ b/src/vm.ts @@ -0,0 +1,14 @@ +import type { AppConfig } from "./config.js"; +import { serveModel } from "./generate/dockerfile-shared.js"; + +/** + * Workload-aware default runner memory. Two consumers share it so they can't + * drift: `deploykit init` pre-fills its per-app memory prompt with this, and the + * fly.toml generator falls back to it when `vm.memory` is unset (older or + * hand-written configs). A static file server needs little; a Next SSR server + * needs headroom (Next commonly OOMs at 512mb); other Node servers sit between. + */ +export function defaultMemory(app: Pick) { + if (serveModel(app) === "static") return "256mb"; + return app.framework === "next" ? "1024mb" : "512mb"; +}