apps/assembler/sst.config.ts
Rama main · réplica de lectura del repositorio original.
// 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.
/// <reference path="./.sst/platform/config.d.ts" />
// 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<string, string | undefined> = {
// 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<string, string> },
});
// 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<string> | 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<string, string>,
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://<ASSEMBLER_DOMAIN> when set, else apiUrl
// apiDomainTarget <- CNAME target for the out-of-band DNS record
return {
apiUrl: api.apiEndpoint,
apiDomainTarget,
serviceUrl,
};
},
});