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
74 changes: 74 additions & 0 deletions cli/shared/deployment/deploy-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,80 @@ describe("DeployProject", () => {
});
});

it("refuses a config the hosted runtime can never evaluate", async () => {
await withDeployEnv(async () => {
const { projectDir } = await createPushedProject();
await Deno.writeTextFile(
`${projectDir}/veryfront.config.ts`,
`import { defineConfig } from "veryfront";\n` +
`import extCssLightning from "@veryfront/ext-css-lightning";\n\n` +
`export default defineConfig({\n extensions: [extCssLightning()],\n});\n`,
);
const controlPlane = new InMemoryDeployControlPlane();
try {
const error = await expectDeployError(() => executeApply(projectDir, controlPlane));

const message = (error as Error).message;
assertStringIncludes(message, "veryfront.config.ts");
assertStringIncludes(message, "@veryfront/ext-css-lightning");
assertEquals(controlPlane.createdReleases, [], "no release for an undeployable config");
assertEquals(
controlPlane.createdDeployments,
[],
"no deployment for an undeployable config",
);
} finally {
await Deno.remove(projectDir, { recursive: true });
}
});
});

it("refuses a literal config the hosted result policy always rejects", async () => {
await withDeployEnv(async () => {
const { projectDir } = await createPushedProject();
// Every construct here is one the hosted evaluator parses happily. It
// refuses the record afterwards, on every request, so a deploy that let
// this through would report success over an environment answering 500.
await Deno.writeTextFile(
`${projectDir}/veryfront.config.ts`,
`export default { cache: { dir: ".tenant-cache" } };\n`,
);
const controlPlane = new InMemoryDeployControlPlane();
try {
const error = await expectDeployError(() => executeApply(projectDir, controlPlane));

assertStringIncludes((error as Error).message, "cache.dir");
assertEquals(controlPlane.createdReleases, [], "no release for an undeployable config");
assertEquals(
controlPlane.createdDeployments,
[],
"no deployment for an undeployable config",
);
} finally {
await Deno.remove(projectDir, { recursive: true });
}
});
});

it("deploys a config that only uses the hosted configuration helpers", async () => {
await withDeployEnv(async () => {
const { projectDir } = await createPushedProject();
await Deno.writeTextFile(
`${projectDir}/veryfront.config.ts`,
`import { defineConfig } from "veryfront";\n\n` +
`export default defineConfig({ title: "Demo" });\n`,
);
const controlPlane = new InMemoryDeployControlPlane();
try {
const outcome = await executeApply(projectDir, controlPlane);

assertEquals(outcome.kind, "deployed", "a hosted-compatible config still deploys");
} finally {
await Deno.remove(projectDir, { recursive: true });
}
});
});

it("deploys a request-scoped project without inferring or persisting a local link", async () => {
await withDeployEnv(async () => {
const { projectDir, files } = await createUnlinkedPushedProject();
Expand Down
56 changes: 55 additions & 1 deletion cli/shared/deployment/deploy-project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type EnvironmentConfig, getConfig, getEnvironmentConfig } from "veryfront/config";
import { findVeryfrontConfigFile } from "#veryfront/config/config-files.ts";
import {
findHostedConfigIncompatibility,
formatHostedConfigIncompatibility,
} from "#veryfront/config/hosted-compatibility.ts";
import { createFileSystem, isNotFoundError, runtime } from "veryfront/platform";
import { join, relative, resolve } from "veryfront/platform/path";
import { isWithinDirectory, normalizePath } from "veryfront/utils";
Expand All @@ -13,6 +18,7 @@ import {
routeForPage,
} from "veryfront/release-assets";
import {
CONFIG_NOT_DEPLOYABLE,
DEPLOYMENT_ERROR,
ENVIRONMENT_NOT_FOUND,
RELEASE_MISSING_VERSION,
Expand Down Expand Up @@ -547,6 +553,46 @@ function resolveProjectRouteDirectory(
return routeRoot;
}

/**
* Refuse a configuration file Veryfront Cloud can never read.
*
* A hosted project's config is evaluated as data, not imported, so a config
* that imports an extension is rejected on every request: the deploy reports
* success and the environment answers 500 to all traffic. Deciding it here
* costs one parse and turns that into a message before anything is created.
*
* Only rejections the source alone decides reach this far (see
* `findHostedConfigIncompatibility`), so a config whose values depend on
* deployment environment variables is never blocked by a difference between
* that environment and the developer's.
*/
async function assertConfigIsDeployable(projectDir: string): Promise<void> {
const fs = createFileSystem();
const configFile = await findVeryfrontConfigFile(projectDir, (path) => fs.exists(path));
if (!configFile) return;

let source: string;
try {
source = await fs.readTextFile(configFile.path);
} catch (error) {
if (isNotFoundError(error)) return;
throw error;
}

const incompatibility = await findHostedConfigIncompatibility(source, configFile.fileName);
if (!incompatibility) return;

throw CONFIG_NOT_DEPLOYABLE.create({
detail: formatHostedConfigIncompatibility(incompatibility, configFile.fileName),
context: {
configFile: configFile.fileName,
code: incompatibility.code,
reason: incompatibility.reason,
...(incompatibility.line === undefined ? {} : { line: incompatibility.line }),
},
});
}

async function collectProjectPageRoutes(projectDir: string): Promise<string[]> {
const fs = createFileSystem();
const directories = await getProjectRouteDirectories(projectDir);
Expand Down Expand Up @@ -1164,7 +1210,15 @@ export function createDeployProject(options: {
const environmentConfig = await step(
observer,
"resolve-config",
async () => getEnvironmentConfig(),
async () => {
// Naming a project deploys what that project already has, so the
// working directory is not the source under review and its config
// must not decide this deploy.
if (request.projectSlug === undefined) {
await assertConfigIsDeployable(request.projectDir);
}
return await getEnvironmentConfig();
},
);
const receipt = await readPushReceipt(request.projectDir);
const branch = request.branch ?? "main";
Expand Down
Loading