scripts/cloudflare-deployment-profile.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* Select the authored core Cloudflare deployment or its optional Sandbox
* overlay. This module is imported by Vite and also backs the cross-platform
* npm convenience commands; no shell-specific environment assignment is
* required.
*/
import { spawnSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { QA_TARGETS } from '../src/config/qa-targets.ts';
export const CORE_DEPLOYMENT_PROFILE = 'core';
export const SANDBOX_DEPLOYMENT_PROFILE = 'sandbox';
export const ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS = QA_TARGETS;
const TARGET_DEFINITIONS = Object.freeze(Object.fromEntries(
ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.map((target) => [target, Object.freeze({
target,
workerName: `chickpea-${target}-live`,
authDatabaseBinding: 'AUTH_DB',
authDatabaseName: `chickpea-auth-db-${target}-live`,
})]),
));
const TARGET_VAR = 'CHICKPEA_DEPLOY_TARGET';
const TARGET_AUTH_DB_ID_VAR = 'CHICKPEA_DEPLOY_AUTH_DB_ID';
const TARGET_SCHEMA_GENERATION_VAR = 'CHICKPEA_DEPLOY_SCHEMA_GENERATION';
const AUTH_DB_SCHEMA_GENERATION_VAR = 'CHICKPEA_AUTH_DB_SCHEMA_GENERATION';
const DURABLE_OBJECT_SCHEMA_GENERATION_VAR = 'CHICKPEA_DURABLE_OBJECT_SCHEMA_GENERATION';
const TARGET_STATE_MODE_VAR = 'CHICKPEA_DEPLOY_STATE_MODE';
const TELEMETRY_ENVIRONMENT_VAR = 'CHICKPEA_TELEMETRY_ENVIRONMENT';
const QA_TELEMETRY_ENVIRONMENT = 'test';
// Vite bundles this helper into a temporary config module, so import.meta.url
// no longer points at `scripts/` during a build. Vite and the npm wrappers run
// from the project root; resolve there to match Wrangler's authored-path
// behavior and keep the generated deploy redirect valid.
const SANDBOX_DOCKERFILE_PATH = path.resolve(process.cwd(), 'Dockerfile');
export function resolveCloudflareDeploymentProfile(value = process.env.CHICKPEA_DEPLOY_PROFILE) {
if (value === undefined || value === '' || value === CORE_DEPLOYMENT_PROFILE) {
return CORE_DEPLOYMENT_PROFILE;
}
if (value === SANDBOX_DEPLOYMENT_PROFILE) return SANDBOX_DEPLOYMENT_PROFILE;
throw new Error(
`Invalid CHICKPEA_DEPLOY_PROFILE=${JSON.stringify(value)}. ` +
'Use "core" (or leave it unset) or use "sandbox".',
);
}
export function resolveCloudflareDeploymentTarget(value = process.env[TARGET_VAR]) {
if (value === undefined || value === '') return undefined;
if (typeof value === 'string' && ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(value)) {
return value;
}
throw new Error(
`Invalid ${TARGET_VAR}=${JSON.stringify(value)}. ` +
`Use exactly one of ${ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.join(', ')}.`,
);
}
export function assertCloudflareQATelemetryEnvironment(value, target) {
if (!ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(target)) {
throw new Error(`Cloudflare QA telemetry target ${JSON.stringify(target)} is invalid.`);
}
if (value !== QA_TELEMETRY_ENVIRONMENT) {
throw new Error(
`Cloudflare deployment target ${target} requires ${TELEMETRY_ENVIRONMENT_VAR}=${QA_TELEMETRY_ENVIRONMENT}.`,
);
}
return QA_TELEMETRY_ENVIRONMENT;
}
function trimmed(value) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function onlyAuthDatabase(config, target) {
const authDatabases = (config.d1_databases ?? []).filter(
(binding) => binding?.binding === 'AUTH_DB',
);
if (authDatabases.length !== 1) {
throw new Error(
`Cloudflare deployment target ${target} requires exactly one AUTH_DB binding.`,
);
}
return authDatabases[0];
}
function d1SchemaGeneration(authDatabase) {
const configuredDirectory = trimmed(authDatabase.migrations_dir);
if (!configuredDirectory) {
throw new Error('Cloudflare deployment target requires reviewed AUTH_DB migrations.');
}
const directory = path.isAbsolute(configuredDirectory)
? configuredDirectory
: path.resolve(process.cwd(), configuredDirectory);
let migrations;
try {
migrations = readdirSync(directory)
.filter((name) => name.endsWith('.sql'))
.sort();
} catch (error) {
throw new Error(
`Unable to resolve the AUTH_DB schema generation: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const current = migrations.at(-1);
if (!current) throw new Error('Unable to resolve the AUTH_DB schema generation: no SQL migrations.');
return path.basename(current, '.sql');
}
function durableObjectSchemaGeneration(config) {
const migrations = config.migrations ?? [];
const tags = migrations.map((migration) => trimmed(migration?.tag));
if (tags.length === 0 || tags.some((tag) => tag === undefined) || new Set(tags).size !== tags.length) {
throw new Error('Unable to resolve one ordered Durable Object schema generation.');
}
return tags.at(-1);
}
function combinedSchemaGeneration(d1Generation, durableObjectGeneration) {
return `d1:${d1Generation};do:${durableObjectGeneration}`;
}
function targetDefinition(target, coordinates) {
const definition = TARGET_DEFINITIONS[target];
if (target !== 'violet' || !coordinates) return definition;
const { workerName, authDatabaseName } = coordinates;
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/u.test(workerName ?? '')
|| !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/u.test(authDatabaseName ?? '')) {
throw new Error('Violet requires exact registered Worker and AUTH_DB names.');
}
return { ...definition, workerName, authDatabaseName };
}
/**
* Validate the selected immutable identity against non-secret registrations
* supplied by the environment-registry layer. This helper intentionally does
* not load or define that registry; callers provide only its resolved target
* coordinates.
*/
export function validateCloudflareDeploymentTargetIdentity(
selected,
registeredTargetIdentities = [],
) {
if (!Array.isArray(registeredTargetIdentities)) {
throw new Error('Cloudflare deployment target identities must be an array.');
}
if (!ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(selected?.target)) {
throw new Error('Cloudflare selected deployment target identity is invalid.');
}
const selectedRegistration = registeredTargetIdentities.find(({ target }) => target === selected.target);
const selectedDefinition = targetDefinition(selected.target, selectedRegistration ?? selected);
if (
selected.workerName !== selectedDefinition.workerName ||
selected.authDatabaseBinding !== selectedDefinition.authDatabaseBinding ||
selected.authDatabaseName !== selectedDefinition.authDatabaseName
) {
throw new Error(
`The immutable AUTH_DB identity for ${selected.target} does not agree with its Worker, binding, and database name.`,
);
}
const immutableOwners = new Map();
for (const registration of registeredTargetIdentities) {
const target = registration?.target;
if (!ACTIVE_CLOUDFLARE_DEPLOYMENT_TARGETS.includes(target)) {
throw new Error(`Cloudflare registered deployment target ${JSON.stringify(target)} is invalid.`);
}
const definition = targetDefinition(target, registration);
const databaseId = trimmed(registration?.authDatabaseId);
const coordinatesAgree =
registration?.workerName === definition.workerName &&
registration?.authDatabaseBinding === definition.authDatabaseBinding &&
registration?.authDatabaseName === definition.authDatabaseName;
if (!coordinatesAgree) {
throw new Error(
`The immutable AUTH_DB identity for ${target} does not agree with its Worker, binding, and database name.`,
);
}
if (
target === selected.target &&
selected.authDatabaseId &&
databaseId &&
databaseId !== selected.authDatabaseId
) {
throw new Error(`The immutable AUTH_DB identity for ${target} disagrees with the selected ID.`);
}
if (!databaseId) continue;
const previousOwner = immutableOwners.get(databaseId);
if (previousOwner && previousOwner !== target) {
throw new Error(
`AUTH_DB identity ${databaseId} is registered to both ${previousOwner} and ${target}.`,
);
}
immutableOwners.set(databaseId, target);
if (selected.authDatabaseId === databaseId && target !== selected.target) {
throw new Error(
`AUTH_DB identity ${databaseId} is registered to ${target}, not selected target ${selected.target}.`,
);
}
}
return selected;
}
function prepareCloudflareDeploymentTarget(config, env, options) {
const target = resolveCloudflareDeploymentTarget(env[TARGET_VAR]);
if (!target) return undefined;
const workerName = trimmed(env.CHICKPEA_DEPLOY_WORKER_NAME);
const authDatabaseName = trimmed(env.CHICKPEA_DEPLOY_AUTH_DB_NAME);
if ((workerName || authDatabaseName) && target !== 'violet') {
throw new Error('Only an adopted Violet installation supports registered resource names.');
}
const definition = targetDefinition(target, workerName || authDatabaseName ? { workerName, authDatabaseName } : undefined);
const authDatabase = onlyAuthDatabase(config, target);
const configuredDatabaseId = trimmed(authDatabase.database_id);
const immutableDatabaseId = trimmed(env[TARGET_AUTH_DB_ID_VAR]);
if (
authDatabase.database_name === definition.authDatabaseName &&
configuredDatabaseId &&
configuredDatabaseId !== immutableDatabaseId
) {
throw new Error(
`Cloudflare deployment target ${target} has AUTH_DB identity ${configuredDatabaseId}, ` +
`but ${TARGET_AUTH_DB_ID_VAR} does not select that immutable database.`,
);
}
const d1Generation = d1SchemaGeneration(authDatabase);
const durableObjectGeneration = durableObjectSchemaGeneration(config);
const schemaGeneration = combinedSchemaGeneration(d1Generation, durableObjectGeneration);
const stateMode = immutableDatabaseId ? 'permanent' : 'disposable';
if (stateMode === 'permanent' && trimmed(env[TARGET_SCHEMA_GENERATION_VAR]) !== schemaGeneration) {
throw new Error(
`Cloudflare permanent target ${target} must be explicitly advanced to schema generation ` +
`${schemaGeneration}; older revisions are barred.`,
);
}
const selected = {
...definition,
authDatabaseId: immutableDatabaseId,
d1SchemaGeneration: d1Generation,
durableObjectSchemaGeneration: durableObjectGeneration,
schemaGeneration,
stateMode,
};
validateCloudflareDeploymentTargetIdentity(
selected,
options.registeredTargetIdentities ?? [],
);
return { selected, authDatabase };
}
export function readCloudflareDeploymentTargetTuple(config, env = process.env) {
const requestedTarget = resolveCloudflareDeploymentTarget(env[TARGET_VAR]);
const stampedTarget = trimmed(config.vars?.[TARGET_VAR]);
if (!requestedTarget) {
if (stampedTarget) {
throw new Error(
`Cloudflare deployment target mismatch: no target selected, generated ${stampedTarget}.`,
);
}
return undefined;
}
const authDatabase = onlyAuthDatabase(config, requestedTarget);
const definition = targetDefinition(requestedTarget, requestedTarget === 'violet' ? {
workerName: trimmed(env.CHICKPEA_DEPLOY_WORKER_NAME) ?? TARGET_DEFINITIONS.violet.workerName,
authDatabaseName: trimmed(env.CHICKPEA_DEPLOY_AUTH_DB_NAME) ?? TARGET_DEFINITIONS.violet.authDatabaseName,
} : undefined);
const immutableDatabaseId = trimmed(env[TARGET_AUTH_DB_ID_VAR]);
const databaseId = trimmed(authDatabase.database_id);
const d1Generation = trimmed(config.vars?.[AUTH_DB_SCHEMA_GENERATION_VAR]);
const durableObjectGeneration = trimmed(config.vars?.[DURABLE_OBJECT_SCHEMA_GENERATION_VAR]);
const schemaGeneration = trimmed(config.vars?.[TARGET_SCHEMA_GENERATION_VAR]);
const stateMode = trimmed(config.vars?.[TARGET_STATE_MODE_VAR]);
const expectedSchemaGeneration = d1Generation && durableObjectGeneration
? combinedSchemaGeneration(d1Generation, durableObjectGeneration)
: undefined;
const failures = [];
if (stampedTarget !== requestedTarget) failures.push(`target stamp ${requestedTarget}`);
if (config.name !== definition.workerName) failures.push(`Worker ${definition.workerName}`);
if (config.topLevelName !== undefined && config.topLevelName !== definition.workerName) {
failures.push(`top-level Worker ${definition.workerName}`);
}
if (authDatabase.database_name !== definition.authDatabaseName) {
failures.push(`AUTH_DB name ${definition.authDatabaseName}`);
}
if (!d1Generation) failures.push('D1 schema generation');
if (!durableObjectGeneration) failures.push('Durable Object schema generation');
if (!schemaGeneration || schemaGeneration !== expectedSchemaGeneration) {
failures.push('one combined schema generation');
}
if (immutableDatabaseId && databaseId !== immutableDatabaseId) {
failures.push(`immutable AUTH_DB ID ${immutableDatabaseId}`);
}
const expectedStateMode = immutableDatabaseId ? 'permanent' : 'disposable';
if (stateMode !== expectedStateMode) failures.push(`${expectedStateMode} state mode`);
try {
assertCloudflareQATelemetryEnvironment(
config.vars?.[TELEMETRY_ENVIRONMENT_VAR],
requestedTarget,
);
} catch {
failures.push(`${TELEMETRY_ENVIRONMENT_VAR}=${QA_TELEMETRY_ENVIRONMENT}`);
}
if (
immutableDatabaseId &&
trimmed(env[TARGET_SCHEMA_GENERATION_VAR]) !== schemaGeneration
) {
failures.push(`permanent schema advancement ${schemaGeneration}`);
}
if (failures.length) {
throw new Error(
`Cloudflare deployment target ${requestedTarget} is missing or unsafe: ${failures.join(', ')}.`,
);
}
return {
...definition,
authDatabaseId: databaseId,
d1SchemaGeneration: d1Generation,
durableObjectSchemaGeneration: durableObjectGeneration,
schemaGeneration,
stateMode,
};
}
export function formatCloudflareDeploymentTargetTuple(tuple) {
return (
`Deployment target: target=${tuple.target} worker=${tuple.workerName} ` +
`auth_db=${tuple.authDatabaseBinding}/${tuple.authDatabaseName} ` +
`auth_db_id=${tuple.authDatabaseId ?? 'disposable'} ` +
`d1_schema=${tuple.d1SchemaGeneration} ` +
`do_schema=${tuple.durableObjectSchemaGeneration} state=${tuple.stateMode}`
);
}
function sandboxResources(config) {
const bindings = config.durable_objects?.bindings ?? [];
const sandboxBindings = bindings.filter(
(binding) => binding?.name === 'SANDBOX' || binding?.class_name === 'Sandbox',
);
const containers = config.containers ?? [];
const sandboxContainers = containers.filter((container) => container?.class_name === 'Sandbox');
return { bindings, sandboxBindings, containers, sandboxContainers };
}
export function classifyCloudflareDeploymentProfile(config) {
const { sandboxBindings, containers, sandboxContainers } = sandboxResources(config);
if (sandboxBindings.length === 0 && containers.length === 0) return CORE_DEPLOYMENT_PROFILE;
const container = sandboxContainers[0];
const effectiveWorkerName = config.topLevelName ?? config.name;
const derivedContainerName =
typeof effectiveWorkerName === 'string' ? `${effectiveWorkerName}-sandbox` : undefined;
if (
sandboxBindings.length === 1 &&
sandboxBindings[0]?.name === 'SANDBOX' &&
sandboxBindings[0]?.class_name === 'Sandbox' &&
containers.length === 1 &&
sandboxContainers.length === 1 &&
container?.instance_type === 'standard-1' &&
container?.max_instances === 25 &&
path.isAbsolute(container?.image ?? '') &&
path.resolve(container.image) === SANDBOX_DOCKERFILE_PATH &&
(container.name === undefined || container.name === derivedContainerName)
) {
return SANDBOX_DEPLOYMENT_PROFILE;
}
throw new Error('Cloudflare artifact contains partial or duplicate Sandbox infrastructure.');
}
export function applyCloudflareDeploymentProfile(config, env = process.env, options = {}) {
const profile = resolveCloudflareDeploymentProfile(env.CHICKPEA_DEPLOY_PROFILE);
if (profile === SANDBOX_DEPLOYMENT_PROFILE && classifyCloudflareDeploymentProfile(config) !== CORE_DEPLOYMENT_PROFILE) {
throw new Error('The Sandbox overlay must be applied to the slim core Cloudflare config.');
}
const preparedTarget = prepareCloudflareDeploymentTarget(config, env, options);
const overrideName = env.WRANGLER_CI_OVERRIDE_NAME;
if (
preparedTarget &&
typeof overrideName === 'string' &&
overrideName.length > 0 &&
overrideName !== preparedTarget.selected.workerName
) {
throw new Error(
`WRANGLER_CI_OVERRIDE_NAME must match selected target Worker ${preparedTarget.selected.workerName}.`,
);
}
if (preparedTarget) {
const { selected, authDatabase } = preparedTarget;
config.name = selected.workerName;
config.topLevelName = selected.workerName;
authDatabase.database_name = selected.authDatabaseName;
authDatabase.database_id = selected.authDatabaseId ?? '';
config.vars = {
...(config.vars ?? {}),
[TARGET_VAR]: selected.target,
[AUTH_DB_SCHEMA_GENERATION_VAR]: selected.d1SchemaGeneration,
[DURABLE_OBJECT_SCHEMA_GENERATION_VAR]: selected.durableObjectSchemaGeneration,
[TARGET_SCHEMA_GENERATION_VAR]: selected.schemaGeneration,
[TARGET_STATE_MODE_VAR]: selected.stateMode,
[TELEMETRY_ENVIRONMENT_VAR]: QA_TELEMETRY_ENVIRONMENT,
};
} else if (typeof overrideName === 'string' && overrideName.length > 0) {
// Wrangler applies this name during Deploy-button builds. Applying it at
// Vite config time too lets the plugin derive a matching, collision-safe
// Container application name instead of reusing `chickpea-sandbox`.
config.name = overrideName;
config.topLevelName = overrideName;
}
if (profile === SANDBOX_DEPLOYMENT_PROFILE) {
config.durable_objects ??= {};
config.durable_objects.bindings ??= [];
config.durable_objects.bindings.push({ name: 'SANDBOX', class_name: 'Sandbox' });
config.containers = [
{
class_name: 'Sandbox',
// The Vite customizer runs after Wrangler has resolved authored paths,
// so keep the generated redirect portable by resolving this new path
// explicitly just as the plugin does for authored container entries.
image: SANDBOX_DOCKERFILE_PATH,
instance_type: 'standard-1',
max_instances: 25,
},
];
// Coding-workspace checkpoints. No bucket_name: Wrangler provisions the
// bucket on the first deploy and reuses it by binding name afterwards, so
// every install path (CLI, Deploy button, Workers Builds) gets one. The
// Worker's maintenance cron deletes checkpoints past their 3-day window.
config.r2_buckets = [...(config.r2_buckets ?? []), { binding: 'BACKUP_BUCKET' }];
}
return preparedTarget?.selected;
}
function runNpmScript(profile, script, forwardedArgs) {
const npmExecPath = process.env.npm_execpath;
const command = npmExecPath ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm';
const args = npmExecPath
? [npmExecPath, 'run', script, ...(forwardedArgs.length ? ['--', ...forwardedArgs] : [])]
: ['run', script, ...(forwardedArgs.length ? ['--', ...forwardedArgs] : [])];
const result = spawnSync(command, args, {
env: { ...process.env, CHICKPEA_DEPLOY_PROFILE: profile },
stdio: 'inherit',
});
if (result.error) throw result.error;
process.exit(result.status ?? 1);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const [profileValue, script, ...forwardedArgs] = process.argv.slice(2);
const profile = resolveCloudflareDeploymentProfile(profileValue);
if (profile !== SANDBOX_DEPLOYMENT_PROFILE || !['build', 'deploy'].includes(script ?? '')) {
console.error(
'Usage: node scripts/cloudflare-deployment-profile.mjs sandbox <build|deploy> [arguments...]',
);
process.exit(2);
}
runNpmScript(profile, script, forwardedArgs);
}