config.ts13.7 KBView on GitHub export type CedarAwsEnvironmentName = 'staging' | 'prod';
export type CedarAwsDeploymentMode = CedarAwsEnvironmentName;
export type CedarAwsSurfaceName =
| 'aws-staging-app'
| 'aws-staging-api'
| 'aws-prod-app'
| 'aws-prod-api';
export const DEFAULT_AWS_REGION = 'us-east-1' as const;
export const ALLOWED_AWS_REGIONS = ['us-east-1', 'us-west-2'] as const;
type CedarAwsRegion = (typeof ALLOWED_AWS_REGIONS)[number];
export type CedarAwsServiceScalingConfig = {
desiredCount: number;
minCapacity: number;
maxCapacity: number;
cpuUtilizationTargetPct: number;
memoryUtilizationTargetPct: number;
scaleOutCooldownSec: number;
scaleInCooldownSec: number;
};
export type CedarAwsDbPoolConfig = {
apiMaxConnections: number;
chatMaxConnections: number;
workerMaxConnections: number;
idleTimeoutSeconds: number;
connectTimeoutSeconds: number;
maxLifetimeSeconds: number;
};
export type CedarAwsEnvironmentConfig = {
name: CedarAwsEnvironmentName;
prefix: 'aws-staging' | 'aws-prod';
secretPrefix: '/cedar/aws-staging' | '/cedar/aws-prod';
customDomains: {
app?: string;
api?: string;
/**
* Extra hostnames the frontend distribution answers on — the marketing site
* (cedarcopilot.com, www.cedarcopilot.com). Same bundle as the app host; which
* half of the site each origin serves is decided client-side in
* apps/mail/lib/marketing-host.ts.
*/
marketing?: string[];
certificateArn?: string;
};
network: {
vpcLookupMode: 'default' | 'id';
vpcId?: string;
};
secretName: string;
secretArn?: string;
surfaces: {
app: CedarAwsSurfaceName;
api: CedarAwsSurfaceName;
};
publicUrls: {
app: string;
backend: string;
/**
* Public resource identifier for Cedar's MCP server (e.g. "https://mcp.cedarcopilot.com"),
* once it's proxied through Manufact for app-store submission. Unset until that domain
* cutover happens — `buildServiceEnvironment` only sets CEDAR_MCP_PUBLIC_URL in the ECS
* task when this is present, so leaving it unset keeps today's behavior unchanged.
*/
mcp?: string;
};
imageUris: {
api: string;
chat: string;
worker: string;
};
dataBuckets: {
importExisting: boolean;
filesBucketName?: string;
};
stackNames: {
environment: string;
data: string;
observability: string;
app: string;
};
apiServiceName: string;
chatServiceName: string;
workerServiceName: string;
serviceScaling: {
api: CedarAwsServiceScalingConfig;
chat: CedarAwsServiceScalingConfig;
worker: CedarAwsServiceScalingConfig;
};
dbPool: CedarAwsDbPoolConfig;
deploymentMode: CedarAwsDeploymentMode;
};
export const DEFAULT_IMAGE_URIS = {
api: 'public.ecr.aws/docker/library/node:22-alpine',
chat: 'public.ecr.aws/docker/library/node:22-alpine',
worker: 'public.ecr.aws/docker/library/busybox:1.36',
} as const;
const DEFAULT_PUBLIC_APP_URLS = {
staging: 'https://mail-staging.cedarcopilot.com',
prod: 'https://mail.cedarcopilot.com',
} as const;
const STAGING_SERVICE_SCALING = {
api: {
desiredCount: 2,
minCapacity: 2,
maxCapacity: 6,
cpuUtilizationTargetPct: 50,
memoryUtilizationTargetPct: 60,
scaleOutCooldownSec: 60,
scaleInCooldownSec: 180,
},
chat: {
desiredCount: 2,
minCapacity: 2,
maxCapacity: 4,
cpuUtilizationTargetPct: 50,
memoryUtilizationTargetPct: 60,
scaleOutCooldownSec: 60,
scaleInCooldownSec: 180,
},
worker: {
desiredCount: 2,
minCapacity: 2,
maxCapacity: 6,
cpuUtilizationTargetPct: 50,
memoryUtilizationTargetPct: 60,
scaleOutCooldownSec: 60,
scaleInCooldownSec: 180,
},
} as const satisfies CedarAwsEnvironmentConfig['serviceScaling'];
const PROD_SERVICE_SCALING = {
api: {
desiredCount: 2,
minCapacity: 2,
maxCapacity: 8,
cpuUtilizationTargetPct: 60,
memoryUtilizationTargetPct: 70,
scaleOutCooldownSec: 60,
scaleInCooldownSec: 180,
},
chat: {
desiredCount: 2,
minCapacity: 2,
maxCapacity: 6,
cpuUtilizationTargetPct: 60,
memoryUtilizationTargetPct: 70,
scaleOutCooldownSec: 60,
scaleInCooldownSec: 180,
},
worker: {
desiredCount: 3,
minCapacity: 3,
maxCapacity: 6,
cpuUtilizationTargetPct: 60,
memoryUtilizationTargetPct: 70,
scaleOutCooldownSec: 60,
scaleInCooldownSec: 180,
},
} as const satisfies CedarAwsEnvironmentConfig['serviceScaling'];
const DEFAULT_STAGING_DB_POOL = {
apiMaxConnections: 30,
chatMaxConnections: 20,
workerMaxConnections: 25,
idleTimeoutSeconds: 10,
connectTimeoutSeconds: 15,
maxLifetimeSeconds: 60 * 15,
} as const satisfies CedarAwsDbPoolConfig;
const DEFAULT_PROD_DB_POOL = {
apiMaxConnections: 35,
chatMaxConnections: 25,
workerMaxConnections: 30,
idleTimeoutSeconds: 10,
connectTimeoutSeconds: 15,
maxLifetimeSeconds: 60 * 15,
} as const satisfies CedarAwsDbPoolConfig;
export function resolveAwsRegion(): CedarAwsRegion {
const region =
process.env.CEDAR_AWS_REGION ??
process.env.AWS_REGION ??
process.env.AWS_DEFAULT_REGION ??
process.env.CDK_DEFAULT_REGION ??
DEFAULT_AWS_REGION;
if (!ALLOWED_AWS_REGIONS.includes(region as CedarAwsRegion)) {
throw new Error(
`Unsupported AWS region "${region}". Cedar AWS deploys must use one of: ${ALLOWED_AWS_REGIONS.join(
', ',
)}.`,
);
}
return region as CedarAwsRegion;
}
function assertDeploymentMode(
name: CedarAwsEnvironmentName,
deploymentMode: CedarAwsDeploymentMode,
): void {
if (name === 'staging' && deploymentMode !== 'staging') {
throw new Error(`Invalid deployment mode "${deploymentMode}" for staging`);
}
if (name === 'prod' && deploymentMode !== 'prod') {
throw new Error(`Invalid deployment mode "${deploymentMode}" for prod`);
}
}
export function resolveDefaultDeploymentMode(
name: CedarAwsEnvironmentName,
): CedarAwsDeploymentMode {
return name;
}
export function buildEnvironmentConfig(
name: CedarAwsEnvironmentName,
deploymentMode = resolveDefaultDeploymentMode(name),
): CedarAwsEnvironmentConfig {
assertDeploymentMode(name, deploymentMode);
const prefix = name === 'staging' ? 'aws-staging' : 'aws-prod';
const secretPrefix = name === 'staging' ? '/cedar/aws-staging' : '/cedar/aws-prod';
const configuredVpcId = process.env.CEDAR_AWS_VPC_ID?.trim() || undefined;
const useDefaultVpc = configuredVpcId ? false : process.env.CEDAR_AWS_USE_DEFAULT_VPC !== 'false';
const apiImageUri = process.env.CEDAR_AWS_API_IMAGE_URI ?? DEFAULT_IMAGE_URIS.api;
const chatImageUri = process.env.CEDAR_AWS_CHAT_IMAGE_URI ?? DEFAULT_IMAGE_URIS.chat;
const workerImageUri = process.env.CEDAR_AWS_WORKER_IMAGE_URI ?? DEFAULT_IMAGE_URIS.worker;
const importExistingDataBuckets = process.env.CEDAR_AWS_IMPORT_DATA_BUCKETS === 'true';
const sharedFilesBucketName =
readEnv('CEDAR_AWS_SHARED_FILES_BUCKET_NAME') ||
'cedar-aws-staging-files-619071350399-us-east-1';
const dbPool = resolveDbPoolConfig(name);
const customDomains = resolveCustomDomains(name);
const publicAppUrlOverride = resolvePublicAppUrlOverride(name);
const publicBackendUrlOverride = resolvePublicBackendUrlOverride(name);
const publicMcpUrlOverride = resolvePublicMcpUrlOverride(name);
const network = configuredVpcId
? { vpcLookupMode: 'id' as const, vpcId: configuredVpcId }
: { vpcLookupMode: useDefaultVpc ? ('default' as const) : ('id' as const) };
if (network.vpcLookupMode === 'id' && !network.vpcId) {
throw new Error(
'AWS VPC lookup is disabled for default VPC, but CEDAR_AWS_VPC_ID was not provided.',
);
}
if (name === 'staging') {
return {
name,
prefix,
secretPrefix,
customDomains,
network,
secretName: `${secretPrefix}/runtime`,
secretArn: process.env.CEDAR_AWS_SECRET_ARN,
surfaces: {
app: 'aws-staging-app',
api: 'aws-staging-api',
},
publicUrls: buildPublicUrls(
DEFAULT_PUBLIC_APP_URLS.staging,
customDomains,
publicAppUrlOverride,
publicBackendUrlOverride,
publicMcpUrlOverride,
),
imageUris: {
api: apiImageUri,
chat: chatImageUri,
worker: workerImageUri,
},
dataBuckets: {
importExisting: importExistingDataBuckets,
filesBucketName: sharedFilesBucketName,
},
stackNames: {
environment: 'CedarAwsStagingEnvironmentStack',
data: 'CedarAwsStagingDataStack',
observability: 'CedarAwsStagingObservabilityStack',
app: 'CedarAwsStagingAppStack',
},
apiServiceName: 'aws-staging-api-service',
chatServiceName: 'aws-staging-chat-service',
workerServiceName: 'aws-staging-worker-service',
serviceScaling: STAGING_SERVICE_SCALING,
dbPool,
deploymentMode,
};
}
return {
name,
prefix,
secretPrefix,
customDomains,
network,
secretName: `${secretPrefix}/runtime`,
secretArn: process.env.CEDAR_AWS_SECRET_ARN,
surfaces: {
app: 'aws-prod-app',
api: 'aws-prod-api',
},
publicUrls: buildPublicUrls(
DEFAULT_PUBLIC_APP_URLS.prod,
customDomains,
publicAppUrlOverride,
publicBackendUrlOverride,
publicMcpUrlOverride,
),
imageUris: {
api: apiImageUri,
chat: chatImageUri,
worker: workerImageUri,
},
dataBuckets: {
importExisting: importExistingDataBuckets,
filesBucketName: sharedFilesBucketName,
},
stackNames: {
environment: 'CedarAwsProdEnvironmentStack',
data: 'CedarAwsProdDataStack',
observability: 'CedarAwsProdObservabilityStack',
app: 'CedarAwsProdAppStack',
},
apiServiceName: 'aws-prod-api-service',
chatServiceName: 'aws-prod-chat-service',
workerServiceName: 'aws-prod-worker-service',
serviceScaling: PROD_SERVICE_SCALING,
dbPool,
deploymentMode,
};
}
function resolveCustomDomains(name: CedarAwsEnvironmentName): CedarAwsEnvironmentConfig['customDomains'] {
const prefix = name === 'staging' ? 'STAGING' : 'PROD';
const app = readEnv(`CEDAR_AWS_${prefix}_APP_DOMAIN`);
const api = readEnv(`CEDAR_AWS_${prefix}_API_DOMAIN`);
const marketing = readListEnv(`CEDAR_AWS_${prefix}_MARKETING_DOMAINS`);
const certificateArn = readEnv(`CEDAR_AWS_${prefix}_CERTIFICATE_ARN`);
if ((app || api || marketing) && !certificateArn) {
throw new Error(
`Custom Cedar AWS domains for ${name} require CEDAR_AWS_${prefix}_CERTIFICATE_ARN.`,
);
}
return {
app,
api,
marketing,
certificateArn,
};
}
function buildPublicUrls(
defaultAppUrl: string,
customDomains: CedarAwsEnvironmentConfig['customDomains'],
publicAppUrlOverride?: string,
publicBackendUrlOverride?: string,
publicMcpUrlOverride?: string,
): CedarAwsEnvironmentConfig['publicUrls'] {
const appUrl = publicAppUrlOverride
? publicAppUrlOverride
: customDomains.app
? `https://${customDomains.app}`
: defaultAppUrl;
const backendUrl = publicBackendUrlOverride
? publicBackendUrlOverride
: customDomains.api
? `https://${customDomains.api}`
: appUrl;
return {
app: appUrl,
backend: backendUrl,
// No customDomains/default fallback, unlike app/backend above — unset means "not cut over
// to Manufact yet", and buildServiceEnvironment must be able to tell that apart from "use
// the app URL", which a fallback would silently produce.
mcp: publicMcpUrlOverride,
};
}
function resolvePublicAppUrlOverride(name: CedarAwsEnvironmentName): string | undefined {
const prefix = name === 'staging' ? 'STAGING' : 'PROD';
return readEnv(`CEDAR_AWS_${prefix}_PUBLIC_APP_URL`);
}
function resolvePublicBackendUrlOverride(name: CedarAwsEnvironmentName): string | undefined {
const prefix = name === 'staging' ? 'STAGING' : 'PROD';
return readEnv(`CEDAR_AWS_${prefix}_PUBLIC_BACKEND_URL`);
}
function resolvePublicMcpUrlOverride(name: CedarAwsEnvironmentName): string | undefined {
const prefix = name === 'staging' ? 'STAGING' : 'PROD';
return readEnv(`CEDAR_AWS_${prefix}_MCP_PUBLIC_URL`);
}
function readEnv(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
/** Comma-separated env list, e.g. "cedarcopilot.com,www.cedarcopilot.com". */
function readListEnv(name: string): string[] | undefined {
const value = readEnv(name);
if (!value) return undefined;
const items = value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return items.length > 0 ? items : undefined;
}
function readPositiveIntegerEnv(name: string): number | undefined {
const value = readEnv(name);
if (!value) {
return undefined;
}
const parsedValue = Number.parseInt(value, 10);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
throw new Error(`Invalid positive integer for ${name}: ${value}`);
}
return parsedValue;
}
function resolveDbPoolConfig(name: CedarAwsEnvironmentName): CedarAwsDbPoolConfig {
const prefix = name === 'staging' ? 'CEDAR_AWS_STAGING' : 'CEDAR_AWS_PROD';
const defaults = name === 'staging' ? DEFAULT_STAGING_DB_POOL : DEFAULT_PROD_DB_POOL;
return {
apiMaxConnections:
readPositiveIntegerEnv(`${prefix}_DB_POOL_MAX_API`) ?? defaults.apiMaxConnections,
chatMaxConnections:
readPositiveIntegerEnv(`${prefix}_DB_POOL_MAX_CHAT`) ?? defaults.chatMaxConnections,
workerMaxConnections:
readPositiveIntegerEnv(`${prefix}_DB_POOL_MAX_WORKER`) ?? defaults.workerMaxConnections,
idleTimeoutSeconds:
readPositiveIntegerEnv(`${prefix}_DB_POOL_IDLE_TIMEOUT_SEC`) ?? defaults.idleTimeoutSeconds,
connectTimeoutSeconds:
readPositiveIntegerEnv(`${prefix}_DB_POOL_CONNECT_TIMEOUT_SEC`) ??
defaults.connectTimeoutSeconds,
maxLifetimeSeconds:
readPositiveIntegerEnv(`${prefix}_DB_POOL_MAX_LIFETIME_SEC`) ??
defaults.maxLifetimeSeconds,
};
}