// SPDX-License-Identifier: AGPL-3.0-only // Carbon (github.com/crbnos/carbon). Modified or adapted versions of this file, // including ports, remain AGPLv3; serving them over a network requires releasing their source. /// // Assembler service infrastructure (SST v4 Ion / Pulumi-based). ONE shared deployment per // environment (commercial + GovCloud/ITAR), NOT per-workspace — point the env at // the target account/region and deploy on its own cadence. // // Two runtimes, ONE image (`carbon/assembler:${IMAGE_TAG}`, same default HTTP // entrypoint): // A. Lambda (default, $0 idle) — the app runs via the Lambda Web Adapter baked // into the Dockerfile (PORT=8000). Create self-invokes an Event-type worker // invocation; the auto time-budget gate keeps jobs under the 900s hard cap. // B. ECS Fargate Spot service (overflow, DEFAULT-OFF) — the same image as a warm // HTTP backend behind an ALB; async submit->poll, no 15-min cap. Enabled only // when ASSEMBLER_ECS_ENABLED=true ("don't scale until someone complains"). // // Spec: .ai/specs/2026-07-15-assembler-deployment.md // // The Lambda + APIGW half is deploy-validated on staging. The ECS half is // typechecked against the generated platform types but has never been // deployed (gated off by default); fill the `DECISION:` markers and validate // on a throwaway stage before first enabling it. export default $config({ app(input) { return { name: "carbon-assembler", home: "aws", // DECISION: commercial vs GovCloud account/region come from the deploy env. region: process.env.AWS_REGION, removal: input?.stage === "prod" ? "retain" : "remove", }; }, async run() { const account = process.env.AWS_ACCOUNT_ID; const region = process.env.AWS_REGION; const imageTag = process.env.IMAGE_TAG ?? "latest"; // Same image for both runtimes: build-images.yml builds it and deploy.yml // copies it into this account's ECR. const image = `${account}.dkr.ecr.${region}.amazonaws.com/carbon/assembler:${imageTag}`; // Shared runtime env (same knobs on Lambda and the ECS service). const environment: Record = { // Bearer key checked in-app on every non-/health route. ASSEMBLER_SERVICE_API_KEY: process.env.ASSEMBLER_SERVICE_API_KEY, // Job/result store — REQUIRED; the assembler refuses to boot without it. REDIS_URL: process.env.REDIS_URL, TZ: "UTC", // Tracing (optional): forwarded only when set at deploy time. Without an // endpoint the service exports nothing. ...Object.fromEntries( [ "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_SERVICE_NAME", "OTEL_TRACES_SAMPLER", "OTEL_TRACES_SAMPLER_ARG", ].flatMap((name) => (process.env[name] ? [[name, process.env[name]]] : [])), ), // Optimize time budget + dispatch mode are AUTO-DETECTED in-service from // AWS_LAMBDA_FUNCTION_NAME (720s ladder budget on Lambda; self-invoke // dispatch) — no env needed here. }; // --------------------------------------------------------------------------- // Runtime A — Lambda (default, $0 idle) // --------------------------------------------------------------------------- // SST's `sst.aws.Function` has no prebuilt-container-image support, so use the // raw provider (SST v4 Ion is Pulumi-based; raw `aws.*` composes fine). const lambdaRole = new aws.iam.Role("AssemblerLambdaRole", { assumeRolePolicy: JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Allow", Principal: { Service: "lambda.amazonaws.com" }, Action: "sts:AssumeRole", }, ], }), }); // Derive the partition so managed-policy ARNs resolve on GovCloud // (`aws-us-gov`) / China (`aws-cn`), not just the commercial `aws` partition. const partition = aws.getPartitionOutput().partition; new aws.iam.RolePolicyAttachment("AssemblerLambdaLogs", { role: lambdaRole.name, // CloudWatch logs only — image pull is handled by the Lambda service + the // ECR repo policy; the function makes no other AWS calls (storage I/O is via // caller-provided signed URLs). policyArn: $interpolate`arn:${partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole`, }); const fn = new aws.lambda.Function("Assembler", { packageType: "Image", imageUri: image, role: lambdaRole.arn, // Async job model on Lambda: create returns 202 and fires the compute as // an Event-type SELF-invocation (its own 900s window); polls read the // shared Redis job store. Requires REDIS_URL. // Default 3008 MB: the cap on accounts without a Service Quotas increase, // so a fresh deploy works out of the box. Lambda's platform max is 10240 — // raise via ASSEMBLER_LAMBDA_MEMORY_MB once the quota bump lands (CPU // scales with memory, so bigger = faster on heavy meshes). memorySize: Number(process.env.ASSEMBLER_LAMBDA_MEMORY_MB ?? "3008"), timeout: 900, // 900s hard cap (not raisable) — the time-budget gate keeps jobs under it // /tmp for the source download + temp GLBs — 10 GB is GA on all accounts // (no quota), so keep the max regardless of the memory tier. ephemeralStorage: { size: Number(process.env.ASSEMBLER_LAMBDA_TMP_MB ?? "10240"), }, // MUST match the built image's platform. arm64 (Graviton) is cheaper + builds // natively on Apple-Silicon dev machines; x86_64 matches the amd64 CI build. architectures: [process.env.ASSEMBLER_LAMBDA_ARCH ?? "x86_64"], environment: { variables: environment as Record }, }); // The create handler invokes this same function (Event type) to run the job. new aws.iam.RolePolicy("AssemblerSelfInvoke", { role: lambdaRole.id, policy: fn.arn.apply((arn) => JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Allow", Action: "lambda:InvokeFunction", Resource: arn }, ], }), ), }); // Public front door: API Gateway HTTP API (not a Function URL — those can't // carry a custom domain and this org's guardrail denies their anonymous // invoke; APIGW's service-principal invoke is allowed, TLS is built in, and // a custom domain attaches later via aws.apigatewayv2.DomainName + ACM). // Every request is short (create -> 202, poll ?wait<=25s) so the 30s // integration cap never binds; the compute runs in the self-invoked worker. // Only /health and /v1/* are routed — the /events worker inlet is // unreachable from outside. In-app bearer stays the real auth gate. const api = new aws.apigatewayv2.Api("AssemblerApi", { protocolType: "HTTP", }); const integration = new aws.apigatewayv2.Integration( "AssemblerIntegration", { apiId: api.id, integrationType: "AWS_PROXY", integrationUri: fn.invokeArn, payloadFormatVersion: "2.0", }, ); for (const [name, routeKey] of [ ["AssemblerRouteHealth", "GET /health"], ["AssemblerRouteV1", "ANY /v1/{proxy+}"], ] as const) { new aws.apigatewayv2.Route(name, { apiId: api.id, routeKey, target: integration.id.apply((id) => `integrations/${id}`), }); } new aws.apigatewayv2.Stage("AssemblerStage", { apiId: api.id, name: "$default", autoDeploy: true, }); new aws.lambda.Permission("AssemblerApiInvoke", { function: fn.name, action: "lambda:InvokeFunction", principal: "apigateway.amazonaws.com", sourceArn: api.executionArn.apply((arn) => `${arn}/*/*`), }); // Custom domain — repo convention: the ACM cert (REGIONAL, same region as // the API) and the DNS record (Cloudflare, proxy OFF) are managed // out-of-band; point a CNAME at the `apiDomainTarget` output. Gated on the // cert: no ASSEMBLER_CERT_ARN => skipped (staging keeps the execute-api URL). const domainName = process.env.ASSEMBLER_DOMAIN ?? "assembler.carbon.ms"; const certArn = process.env.ASSEMBLER_CERT_ARN; let apiDomainTarget: $util.Output | undefined; if (certArn) { const domain = new aws.apigatewayv2.DomainName("AssemblerDomain", { domainName, domainNameConfiguration: { certificateArn: certArn, endpointType: "REGIONAL", securityPolicy: "TLS_1_2", }, }); new aws.apigatewayv2.ApiMapping("AssemblerDomainMapping", { apiId: api.id, domainName: domain.id, stage: "$default", }); apiDomainTarget = domain.domainNameConfiguration.targetDomainName; } // --------------------------------------------------------------------------- // Runtime B — ECS Fargate Spot service (overflow, DEFAULT-OFF) // --------------------------------------------------------------------------- // Only stood up when ASSEMBLER_ECS_ENABLED=true. Off => $0 standing; overflow // jobs degrade (the app router falls back to poster tier). On => >=1 warm Spot // task; Spot interruptions self-heal via the service scheduler. const ecsEnabled = process.env.ASSEMBLER_ECS_ENABLED === "true"; let serviceUrl: string | undefined; if (ecsEnabled) { // Public-subnet VPC, NO NAT — the service only needs egress to storage over // the public internet (signed URLs), which a public subnet + IGW gives for // free. DECISION: reuse the ERP/MES VPC instead if same-account/same-region. // No `nat` arg => no NAT gateway (verified against the platform types); // tasks get public IPs + IGW egress for the signed-URL storage I/O. const vpc = new sst.aws.Vpc("AssemblerVpc"); const cluster = new sst.aws.Cluster("AssemblerCluster", { vpc }); // cluster.addService is deprecated — Service takes the cluster directly. const service = new sst.aws.Service("AssemblerService", { cluster, // Must match the image platform, same as the Lambda architectures arg. architecture: process.env.ASSEMBLER_LAMBDA_ARCH === "arm64" ? "arm64" : "x86_64", cpu: "4 vCPU", memory: "16 GB", // DECISION: the big-job tier; size to the largest expected model image, // Fargate Spot: 50-70% off; interruptions self-heal via the scheduler. capacity: "spot", scaling: { // Default-off is expressed by not deploying the service at all (the // `ecsEnabled` gate); when deployed, hold >=1 warm task. min: Number(process.env.ASSEMBLER_ECS_MIN ?? "1"), max: Number(process.env.ASSEMBLER_ECS_MAX ?? "4"), cpuUtilization: 70, memoryUtilization: 80, }, loadBalancer: { domain: { // DECISION: hostname + ACM cert (e.g. assembler-svc.carbon.ms). Internal // vs public+bearer is a decision; public+bearer here to match the Lambda. name: process.env.ASSEMBLER_SERVICE_HOSTNAME ?? "assembler-svc.carbon.ms", dns: false, cert: process.env.ASSEMBLER_SERVICE_CERT_ARN, }, health: { "8000/http": { path: "/health" } }, ports: [ { listen: "80/http", forward: "8000/http" }, { listen: "443/https", forward: "8000/http" }, ], }, environment: environment as Record, transform: { loadBalancer: { idleTimeout: 600 }, target: (args: { healthCheck?: unknown }) => { args.healthCheck = { enabled: true, path: "/health", protocol: "HTTP", }; }, }, }); serviceUrl = service.url as unknown as string; } // Outputs — wire these into the consumers' env at the human deploy step: // ASSEMBLER_SERVICE_URL <- https:// when set, else apiUrl // apiDomainTarget <- CNAME target for the out-of-band DNS record return { apiUrl: api.apiEndpoint, apiDomainTarget, serviceUrl, }; }, });