Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/apps-generate-dockerfile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

`bunny apps deploy` and `bunny apps init` offer to generate a Dockerfile when a project has none, detecting Node, Bun, Deno, Python, Go, Rust, Ruby, PHP, .NET and Spring Boot apps, down to a single `index.php`, `main.go`, `main.py` or `index.ts`. Agents can deploy without prompts: `--dockerfile` generates the file when it's missing, the new `--region` flag picks the region, and every other question takes its default
5 changes: 5 additions & 0 deletions .changeset/apps-split-volumes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

`bunny apps deploy` and `bunny apps push` refuse an app that mounts a volume in more than one region or scales above one instance, since each copy gets its own empty volume; pass `--allow-split-volumes` to deploy it anyway
93 changes: 69 additions & 24 deletions packages/cli/src/commands/apps/APPS.md

Large diffs are not rendered by default.

61 changes: 60 additions & 1 deletion packages/cli/src/commands/apps/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { useTempDir } from "@/test-utils/temp-dir.ts";
import {
assertVolumesNotSplit,
type BunnyAppConfig,
configExists,
loadConfig,
saveConfig,
Expand Down Expand Up @@ -172,3 +174,60 @@ test("load → save → reload preserves intent fields", () => {
"daemon off;",
]);
});

describe("assertVolumesNotSplit", () => {
const app = (
regions: BunnyAppConfig["app"]["regions"],
{ volumes = true, max = 1 } = {},
): BunnyAppConfig => ({
version: "2026-05-11",
app: {
name: "x",
regions,
scaling: { min: 1, max },
containers: {
web: {},
db: volumes
? { volumes: [{ name: "data", mount: "/data", size: 1 }] }
: {},
},
},
});

test("refuses a volume in more than one region, naming them", () => {
expect(() => assertVolumesNotSplit(app(["de", "uk"]))).toThrow(
'Container "db" mounts a volume, but the app runs in 2 regions (de, uk).',
);
});

test("refuses a volume on more than one instance", () => {
expect(() => assertVolumesNotSplit(app(["de"], { max: 3 }))).toThrow(
'Container "db" mounts a volume, but the app scales to 3 instances.',
);
});

test("names both when regions and scaling split it", () => {
expect(() => assertVolumesNotSplit(app(["de", "uk"], { max: 2 }))).toThrow(
"runs in 2 regions (de, uk) and scales to 2 instances.",
);
});

test("counts the regions an app may run in, not just the required ones", () => {
expect(() =>
assertVolumesNotSplit(app({ allowed: ["de", "uk"], required: ["de"] })),
).toThrow();
});

test.each([
["one region and one instance", app(["de"])],
["no volume", app(["de", "uk"], { volumes: false, max: 3 })],
])("allows %s", (_, config) => {
expect(() => assertVolumesNotSplit(config)).not.toThrow();
});

test("--allow-split-volumes opts in", () => {
expect(() =>
assertVolumesNotSplit(app(["de", "uk"], { max: 3 }), true),
).not.toThrow();
});
});
38 changes: 37 additions & 1 deletion packages/cli/src/commands/apps/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type BunnyAppConfig, BunnyAppConfigSchema } from "@bunny.net/config";
import {
type BunnyAppConfig,
BunnyAppConfigSchema,
normalizeRegions,
} from "@bunny.net/config";
import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts";
import {
CONFIG_FILENAME,
Expand Down Expand Up @@ -128,6 +132,38 @@ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void {
}
}

/**
* Refuse a config that mounts a volume in an app that can run more than one
* copy. Volumes are never shared: every region, and every instance within a
* region, gets its own empty volume, so the data silently splits.
* `--allow-split-volumes` opts in for apps that want that (a cache, say).
*/
export function assertVolumesNotSplit(
config: BunnyAppConfig,
allow?: boolean,
): void {
if (allow) return;
const withVolume = Object.entries(config.app.containers).find(
([, container]) => container.volumes?.length,
);
if (!withVolume) return;

const { allowed } = normalizeRegions(config.app.regions);
const max = config.app.scaling?.max ?? 1;
const reasons = [
...(allowed.length > 1
? [`runs in ${allowed.length} regions (${allowed.join(", ")})`]
: []),
...(max > 1 ? [`scales to ${max} instances`] : []),
];
if (reasons.length === 0) return;

throw new UserError(
`Container "${withVolume[0]}" mounts a volume, but the app ${reasons.join(" and ")}.`,
"Volumes aren't shared: each region and each instance gets its own empty volume. Use one region and `scaling.max: 1` in bunny.jsonc, or pass --allow-split-volumes if each copy should keep separate data.",
);
}

/**
* Resolve the active app ID.
*
Expand Down
35 changes: 31 additions & 4 deletions packages/cli/src/commands/apps/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import { formatTable } from "@/core/format.ts";
import { logger } from "@/core/logger.ts";
import { loadManifest, saveManifest } from "@/core/manifest.ts";
import type { OutputFormat } from "@/core/types.ts";
import { spinner } from "@/core/ui.ts";
import { isInteractive, spinner } from "@/core/ui.ts";
import {
assertVolumesNotSplit,
type BunnyAppConfig,
type ContainerConfig,
configExists,
Expand Down Expand Up @@ -164,6 +165,8 @@ const DEFAULT_DOCKERFILE = "Dockerfile";

interface DeployArgs {
image?: string;
"allow-split-volumes"?: boolean;
region?: string;
dockerfile?: string | boolean;
context?: string;
tag?: string;
Expand All @@ -182,7 +185,14 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
describe: DESCRIPTION,
examples: [
["$0 apps deploy ghcr.io/me/api:v1.2", "Deploy a pre-built image"],
["$0 apps deploy --dockerfile", "Build ./Dockerfile and deploy"],
[
"$0 apps deploy --dockerfile",
"Build ./Dockerfile and deploy, generating it when there is none",
],
[
"$0 apps deploy --dockerfile --region de --output json",
"First deploy without prompts (agents)",
],
[
"$0 apps deploy --dockerfile apps/api/Dockerfile --context apps/api",
"Build with explicit context",
Expand Down Expand Up @@ -227,6 +237,11 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
describe:
"App name (used during first-run walkthrough; skips the interactive prompt)",
})
.option("region", {
type: "string",
describe:
"Region ID for a new app (skips the interactive prompt; required without a terminal)",
})
.option("port", {
type: "number",
describe:
Expand All @@ -247,6 +262,11 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
describe:
"Print the would-be bunny.jsonc and Dockerfile without writing anything or contacting the API to deploy",
})
.option("allow-split-volumes", {
type: "boolean",
describe:
"Deploy a volume to more than one region or instance, each keeping its own separate data",
})
.option("no-push", {
type: "boolean",
describe: "Build only — skip push and deploy",
Expand Down Expand Up @@ -285,7 +305,7 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
// walkthrough also checks this on its interactive picks, but for
// `--dockerfile` (with no existing config) the choice is implicit so
// it'd otherwise skip the menu and only blow up after writing files.
if (dockerfileFlag) {
if (dockerfileFlag && !dryRun) {
await ensureDockerAvailable();
}

Expand All @@ -304,19 +324,26 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
portOverride: args.port,
commandOverride: args.command,
nameOverride: args.name,
regionFlag: args.region,
interactive: isInteractive(output),
buildsImage: !dryRun,
configPath,
dryRun,
});
toml = result.config;
walkthroughRegistries = result.registries;
} else {
toml = loadConfig(configPath);
// Before the Docker check and any build: an edited bunny.jsonc is
// the only way to reach more than one region or instance (the
// walkthrough picks one of each).
assertVolumesNotSplit(toml, args["allow-split-volumes"]);
// Existing config: bail before any registry prompts if a container
// declares a `dockerfile` (we'll need Docker to build it).
const hasAnyBuild = Object.values(toml.app.containers).some((c) =>
Boolean(c.dockerfile),
);
if (hasAnyBuild) {
if (hasAnyBuild && !dryRun) {
await ensureDockerAvailable();
}
}
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/apps/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { dockerLogin, imageHostname } from "@/core/docker.ts";
import { ApiError, UserError } from "@/core/errors.ts";
import { logger } from "@/core/logger.ts";
import { prompts, spinner } from "@/core/ui.ts";
import { GENERATED_HEADER, GITHUB_HINT } from "./dockerfile/project.ts";

export {
dockerLogin,
Expand Down Expand Up @@ -299,9 +300,14 @@ export async function buildImage(
const exitCode = await proc.exited;

if (exitCode !== 0) {
const generated = readFileSync(dockerfileAbs, "utf-8").startsWith(
GENERATED_HEADER,
);
throw new UserError(
`Docker build failed (exit code ${exitCode}).`,
"Check the Dockerfile and build output above for errors.",
generated
? `Check the build output above, then edit the generated Dockerfile to fit the project. ${GITHUB_HINT}`
: "Check the Dockerfile and build output above for errors.",
);
}
}
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/commands/apps/dockerfile/deno.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type DetectedApp, dockerfile, type Project } from "./project.ts";

const ENTRIES = ["main.ts", "main.js", "main.mjs", "main.mts", "server.ts"];

export function detectDeno(project: Project): DetectedApp | undefined {
if (!project.entries.some((e) => e === "deno.json" || e === "deno.jsonc")) {
return undefined;
}
const entry = ENTRIES.find((e) => project.entries.includes(e));
if (!entry) return undefined;

return {
label: "Deno",
// Deno.serve listens on 8000 on every interface unless told otherwise.
port: 8000,
ignore: ["node_modules"],
dockerfile: (port) =>
dockerfile("Deno", [
"FROM denoland/deno:2",
"WORKDIR /app",
"COPY . .",
`RUN deno cache ${entry}`,
`ENV PORT=${port}`,
`EXPOSE ${port}`,
`CMD ["deno", "run", "--allow-all", "${entry}"]`,
]),
};
}
69 changes: 69 additions & 0 deletions packages/cli/src/commands/apps/dockerfile/dotnet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
type DetectedApp,
dockerfile,
type Project,
versionFrom,
} from "./project.ts";

// Read by `dotnet restore`, so copied with the project file to cache it.
const RESTORE_FILES = [
"global.json",
"nuget.config",
"NuGet.config",
"Directory.Build.props",
"Directory.Packages.props",
];

export function detectDotnet(project: Project): DetectedApp | undefined {
// ASP.NET Core apps use the Web SDK; Blazor WebAssembly is a static site.
const csproj = project.entries
.filter((e) => e.endsWith(".csproj"))
.map((file) => ({ file, text: project.read(file) ?? "" }))
.find(({ text }) => text.includes('Sdk="Microsoft.NET.Sdk.Web"'));
if (!csproj) return undefined;

const assembly =
csproj.text.match(/<AssemblyName>([^<]+)</)?.[1] ??
csproj.file.replace(/\.csproj$/, "");
// The runtime matches the target framework; the SDK follows global.json
// when it pins one, since restore refuses an older SDK than it names.
const runtime = versionFrom(
[csproj.text],
/<TargetFrameworks?>[^<]*?net(\d+\.\d+)/,
"10.0",
);
const sdk = versionFrom(
[project.read("global.json")],
/"version":\s*"(\d+\.\d+)/,
runtime,
);
// Referenced projects live elsewhere in the tree, so restore needs it all.
const restore = csproj.text.includes("<ProjectReference")
? ["COPY . .", `RUN dotnet restore ${csproj.file}`]
: [
`COPY ${[csproj.file, ...RESTORE_FILES.filter((f) => project.entries.includes(f))].join(" ")} ./`,
`RUN dotnet restore ${csproj.file}`,
"COPY . .",
];

return {
label: "ASP.NET Core",
port: 8080,
name: assembly.toLowerCase(),
ignore: ["bin", "obj"],
dockerfile: (port) =>
dockerfile("ASP.NET Core", [
`FROM mcr.microsoft.com/dotnet/sdk:${sdk} AS build`,
"WORKDIR /src",
...restore,
`RUN dotnet publish ${csproj.file} -c Release -o /out --no-restore`,
"",
`FROM mcr.microsoft.com/dotnet/aspnet:${runtime}`,
"WORKDIR /app",
"COPY --from=build /out ./",
`ENV ASPNETCORE_URLS=http://+:${port}`,
`EXPOSE ${port}`,
`CMD ${JSON.stringify(["dotnet", `${assembly}.dll`])}`,
]),
};
}
Loading
Loading