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,
  };
}