feat: Fargate

This commit is contained in:
Zakaria
2026-10-02 09:53:45 -04:00
parent 7f08f5b668
commit 2d8ff50a99
27 changed files with 574 additions and 35 deletions
@@ -21,10 +21,25 @@ describe('AwsCodeBuildAdapter', () => {
expect(resources[0].metadata).toMatchObject({sourceType: 'NO_SOURCE', artifactsType: 'NO_ARTIFACTS'})
})
test('creates only the supported no-source, no-artifacts project contract', async () => {
test('creates a privileged Docker-to-ECR project with no artifacts', async () => {
const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {})
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole}})
expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}})
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole, buildspec: 'version: 0.2', ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}})
expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE', buildspec: 'version: 0.2'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL', privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: 'orders'}]}})
})
test('creates S3 source and artifact contracts with standard CodeBuild fields', async () => {
const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {})
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'S3', sourceBucket: 'source-bucket', sourceKey: 'releases/orders.zip', buildspec: 'buildspec.local.yml', artifactsType: 'S3', artifactsBucket: 'artifact-bucket', artifactsPrefix: 'builds/orders', ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}})
expect((sent[0] as CreateProjectCommand).input).toMatchObject({source: {type: 'S3', location: 'source-bucket/releases/orders.zip', buildspec: 'buildspec.local.yml'}, artifacts: {type: 'S3', location: 'artifact-bucket', path: 'builds/orders'}})
})
test('rejects unsupported source types and invalid S3 source configuration', async () => {
const {client} = stub(() => ({}))
const adapter = new AwsCodeBuildAdapter(client)
const docker = {ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}
await expect(adapter.create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'GITHUB', ...docker}})).rejects.toThrow('sourceType must be S3 or NO_SOURCE')
await expect(adapter.create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'S3', sourceBucket: 'Invalid_Bucket', sourceKey: 'orders.zip', ...docker}})).rejects.toThrow('sourceBucket must be a valid S3 bucket name')
await expect(adapter.create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'S3', sourceBucket: 'source-bucket', ...docker}})).rejects.toThrow('sourceKey is required')
})
test('starts builds with AWS environment-variable overrides and exposes log metadata', async () => {
@@ -50,12 +50,23 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter {
const image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0'
const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL
if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL')
const sourceType = supportedType(input.values.sourceType, 'sourceType', 'NO_SOURCE')
const artifactsType = supportedType(input.values.artifactsType, 'artifactsType', 'NO_ARTIFACTS')
const ecrRepository = required(input.values.ecrRepository, 'ecrRepository')
if (optional(input.values.dockerPrivilegedMode) !== 'enabled') throw new ValidationError('dockerPrivilegedMode must be enabled')
const buildspec = optional(input.values.buildspec)
const source = sourceType === 'S3'
? {type: 'S3' as const, location: s3Location(input.values.sourceBucket, input.values.sourceKey, 'source'), buildspec}
: {type: 'NO_SOURCE' as const, buildspec}
const artifacts = artifactsType === 'S3'
? {type: 'S3' as const, location: s3Bucket(input.values.artifactsBucket, 'artifactsBucket'), path: optional(input.values.artifactsPrefix)}
: {type: 'NO_ARTIFACTS' as const}
const response = await this.client.send(new CreateProjectCommand({
name,
serviceRole,
source: {type: 'NO_SOURCE'},
artifacts: {type: 'NO_ARTIFACTS'},
environment: {type: 'LINUX_CONTAINER', image, computeType},
source,
artifacts,
environment: {type: 'LINUX_CONTAINER', image, computeType, privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: ecrRepository}]},
}))
if (!response.project) throw new RuntimeError(`CodeBuild did not return project ${name}`)
return toResource(response.project)
@@ -69,6 +80,7 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter {
const response = await this.client.send(new StartBuildCommand({
projectName,
environmentVariablesOverride: input.environmentVariablesOverride,
buildspecOverride: input.buildspecOverride,
}))
if (!response.build) throw new RuntimeError(`CodeBuild did not return a build for ${projectName}`)
return toBuild(response.build)
@@ -122,6 +134,8 @@ function toResource(project: Project): CloudResource {
environmentType: project.environment?.type,
environmentImage: project.environment?.image,
computeType: project.environment?.computeType,
dockerPrivilegedMode: project.environment?.privilegedMode ?? false,
ecrRepository: project.environment?.environmentVariables?.find((variable) => variable.name === 'ECR_REPOSITORY')?.value,
},
}
}
@@ -147,6 +161,26 @@ function optional(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function supportedType(value: unknown, field: string, fallback: 'S3' | 'NO_SOURCE' | 'NO_ARTIFACTS'): 'S3' | 'NO_SOURCE' | 'NO_ARTIFACTS' {
const type = optional(value) ?? fallback
if (type !== 'S3' && type !== 'NO_SOURCE' && type !== 'NO_ARTIFACTS') {
throw new ValidationError(field === 'sourceType' ? 'sourceType must be S3 or NO_SOURCE' : 'artifactsType must be S3 or NO_ARTIFACTS')
}
if (field === 'sourceType' && type === 'NO_ARTIFACTS') throw new ValidationError('sourceType must be S3 or NO_SOURCE')
if (field === 'artifactsType' && type === 'NO_SOURCE') throw new ValidationError('artifactsType must be S3 or NO_ARTIFACTS')
return type
}
function s3Location(bucket: unknown, key: unknown, field: string): string {
return `${s3Bucket(bucket, `${field}Bucket`)}/${required(key, `${field}Key`)}`
}
function s3Bucket(value: unknown, field: string): string {
const bucket = required(value, field)
if (!/^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$/.test(bucket)) throw new ValidationError(`${field} must be a valid S3 bucket name`)
return bucket
}
function regionFromArn(arn?: string): string | null {
return arn?.split(':')[3] || null
}
@@ -117,7 +117,7 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter {
const group = response.deploymentGroupInfo
if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`)
const configuration = group.deploymentConfigName
return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}}
return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}}
}
}
@@ -9,15 +9,15 @@ function stub(handler: (command: object) => Record<string, unknown>) {
}
describe('AwsCodePipelineAdapter', () => {
test('creates the documented S3, CodeBuild, S3 local pipeline definition', async () => {
test('creates the documented S3, CodeBuild, CodeDeployToECS pipeline definition', async () => {
const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: []}
const {client, sent} = stub((command) => command instanceof CreatePipelineCommand ? {pipeline} : {})
await new AwsCodePipelineAdapter(client).create({values: {name: 'release', roleArn: pipeline.roleArn, artifactBucket: 'artifacts', sourceBucket: 'source', sourceKey: 'app.zip', codeBuildProject: 'build-app', deployBucket: 'deploy', deployKey: 'releases/app.zip'}})
await new AwsCodePipelineAdapter(client).create({values: {name: 'release', roleArn: pipeline.roleArn, artifactBucket: 'artifacts', sourceBucket: 'source', sourceKey: 'app.zip', codeBuildProject: 'build-app', codeDeployApplication: 'orders', codeDeployDeploymentGroup: 'orders-bluegreen', taskDefinitionPath: 'taskdef.json', appSpecPath: 'appspec.yaml', imageContainerName: 'IMAGE1_NAME'}})
const stages = (sent[0] as CreatePipelineCommand).input.pipeline?.stages
expect(stages?.map((stage) => stage.name)).toEqual(['Source', 'Build', 'Deploy'])
expect(stages?.map((stage) => stage.actions?.[0].actionTypeId?.provider)).toEqual(['S3', 'CodeBuild', 'S3'])
expect(stages?.map((stage) => stage.actions?.[0].actionTypeId?.provider)).toEqual(['S3', 'CodeBuild', 'CodeDeployToECS'])
expect(stages?.[1].actions?.[0].configuration).toEqual({ProjectName: 'build-app'})
expect(stages?.[2].actions?.[0].configuration).toEqual({BucketName: 'deploy', ObjectKey: 'releases/app.zip', Extract: 'true'})
expect(stages?.[2].actions?.[0].configuration).toEqual({ApplicationName: 'orders', DeploymentGroupName: 'orders-bluegreen', TaskDefinitionTemplateArtifact: 'BuildOutput', TaskDefinitionTemplatePath: 'taskdef.json', AppSpecTemplateArtifact: 'BuildOutput', AppSpecTemplatePath: 'appspec.yaml', Image1ArtifactName: 'BuildOutput', Image1ContainerName: 'IMAGE1_NAME'})
})
test('uses native execution and state APIs and retains CodeBuild links', async () => {
@@ -49,7 +49,7 @@ export class AwsCodePipelineAdapter implements CloudServiceAdapter {
stages: [
stage('Source', action('Source', 'Source', 'S3', {S3Bucket: required(input.values.sourceBucket, 'sourceBucket'), S3ObjectKey: required(input.values.sourceKey, 'sourceKey'), PollForSourceChanges: 'false'}, [], ['SourceOutput'])),
stage('Build', action('Build', 'Build', 'CodeBuild', {ProjectName: required(input.values.codeBuildProject, 'codeBuildProject')}, ['SourceOutput'], ['BuildOutput'])),
stage('Deploy', action('Deploy', 'Deploy', 'S3', {BucketName: required(input.values.deployBucket, 'deployBucket'), ObjectKey: required(input.values.deployKey, 'deployKey'), Extract: 'true'}, ['BuildOutput'], [])),
stage('Deploy', ecsDeployAction(input.values)),
],
}
const response = await this.client.send(new CreatePipelineCommand({pipeline: declaration}))
@@ -82,8 +82,8 @@ export class AwsCodePipelineAdapter implements CloudServiceAdapter {
const [pipeline, state] = await Promise.all([this.client.send(new GetPipelineCommand({name: id})), this.client.send(new GetPipelineStateCommand({name: id}))])
const actions = (pipeline.pipeline?.stages ?? []).flatMap((stage) => stage.actions ?? [])
const codeBuildProjects = new Map(actions.filter((item) => item.actionTypeId?.provider === 'CodeBuild').map((item) => [item.name, item.configuration?.ProjectName] as const))
const codeDeployActions = new Set(actions.filter((item) => item.actionTypeId?.provider === 'CodeDeploy').map((item) => item.name))
return {stages: (state.stageStates ?? []).map((stage) => ({name: stage.stageName ?? 'Unknown', status: stage.latestExecution?.status, actions: (stage.actionStates ?? []).map((actionState) => toActionState(actionState, codeBuildProjects, codeDeployActions))}))}
const ecsDeployActions = new Map(actions.filter((item) => item.actionTypeId?.provider === 'CodeDeployToECS').map((item) => [item.name, {application: item.configuration?.ApplicationName, deploymentGroup: item.configuration?.DeploymentGroupName}] as const))
return {stages: (state.stageStates ?? []).map((stage) => ({name: stage.stageName ?? 'Unknown', status: stage.latestExecution?.status, actions: (stage.actionStates ?? []).map((actionState) => toActionState(actionState, codeBuildProjects, ecsDeployActions))}))}
}
}
@@ -97,12 +97,14 @@ function toSummaryResource(pipeline: {name?: string; version?: number; created?:
return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: null, createdAt: pipeline.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, updatedAt: pipeline.updated?.toISOString()}}
}
function toActionState(action: ActionState, codeBuildProjects: Map<string | undefined, string | undefined>, codeDeployActions: Set<string | undefined>) {
function toActionState(action: ActionState, codeBuildProjects: Map<string | undefined, string | undefined>, ecsDeployActions: Map<string | undefined, {application?: string; deploymentGroup?: string; cluster?: string; service?: string}>) {
const execution = action.latestExecution
return {name: action.actionName ?? 'Unknown', status: execution?.status, summary: execution?.summary, lastStatusChange: execution?.lastStatusChange, externalExecutionId: execution?.externalExecutionId, externalExecutionUrl: execution?.externalExecutionUrl, entityUrl: action.entityUrl, revisionUrl: action.revisionUrl, codeBuildProject: codeBuildProjects.get(action.actionName), codeDeployDeploymentId: codeDeployActions.has(action.actionName) ? execution?.externalExecutionId : undefined}
const ecsDeploy = ecsDeployActions.get(action.actionName)
return {name: action.actionName ?? 'Unknown', status: execution?.status, summary: execution?.summary, lastStatusChange: execution?.lastStatusChange, externalExecutionId: execution?.externalExecutionId, externalExecutionUrl: execution?.externalExecutionUrl, entityUrl: action.entityUrl, revisionUrl: action.revisionUrl, codeBuildProject: codeBuildProjects.get(action.actionName), codeDeployDeploymentId: ecsDeploy ? execution?.externalExecutionId : undefined, codeDeployApplication: ecsDeploy?.application, codeDeployDeploymentGroup: ecsDeploy?.deploymentGroup}
}
function stage(name: string, item: ReturnType<typeof action>) { return {name, actions: [item]} }
function stage(name: string, item: {name: string; actionTypeId: {category: 'Source' | 'Build' | 'Deploy'; owner: 'AWS'; provider: string; version: string}; configuration: Record<string, string>; inputArtifacts: Array<{name: string}>; outputArtifacts: Array<{name: string}>}) { return {name, actions: [item]} }
function action(name: string, category: 'Source' | 'Build' | 'Deploy', provider: 'S3' | 'CodeBuild', configuration: Record<string, string>, inputArtifacts: string[], outputArtifacts: string[]) { return {name, actionTypeId: {category, owner: 'AWS' as const, provider, version: '1'}, configuration, inputArtifacts: inputArtifacts.map((name) => ({name})), outputArtifacts: outputArtifacts.map((name) => ({name}))} }
function ecsDeployAction(values: Record<string, unknown>) { return {name: 'Deploy', actionTypeId: {category: 'Deploy' as const, owner: 'AWS' as const, provider: 'CodeDeployToECS', version: '1'}, configuration: {ApplicationName: required(values.codeDeployApplication, 'codeDeployApplication'), DeploymentGroupName: required(values.codeDeployDeploymentGroup, 'codeDeployDeploymentGroup'), TaskDefinitionTemplateArtifact: 'BuildOutput', TaskDefinitionTemplatePath: required(values.taskDefinitionPath, 'taskDefinitionPath'), AppSpecTemplateArtifact: 'BuildOutput', AppSpecTemplatePath: required(values.appSpecPath, 'appSpecPath'), Image1ArtifactName: 'BuildOutput', Image1ContainerName: required(values.imageContainerName, 'imageContainerName')}, inputArtifacts: [{name: 'BuildOutput'}], outputArtifacts: []} }
function required(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`); return value.trim() }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
@@ -0,0 +1,93 @@
import {describe, expect, test} from 'bun:test'
import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient} from '@aws-sdk/client-ecs'
import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2'
import {AwsEcsAdapter} from './AwsEcsAdapter'
import {AwsFargateAdapter} from './AwsFargateAdapter'
const cluster = 'orders-cluster'
describe('AwsEcsAdapter', () => {
test('lists EC2 and Fargate services with task, IP target, and ALB health', async () => {
const ecs = {async send(command: object) {
if (command instanceof ListClustersCommand) return {clusterArns: [cluster]}
if (command instanceof ListServicesCommand) return {serviceArns: ['orders', 'fargate']}
if (command instanceof DescribeServicesCommand) return {services: [
{serviceName: 'orders', serviceArn: 'arn:aws:ecs:us-east-1:000000000000:service/orders', launchType: 'EC2', status: 'ACTIVE', desiredCount: 2, runningCount: 1, pendingCount: 1, taskDefinition: 'orders:4'},
{serviceName: 'fargate', serviceArn: 'arn:aws:ecs:us-east-1:000000000000:service/fargate', launchType: 'FARGATE', status: 'ACTIVE', desiredCount: 1, runningCount: 1, taskDefinition: 'fargate:1', networkConfiguration: {awsvpcConfiguration: {subnets: ['subnet-1'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}}, loadBalancers: [{targetGroupArn: 'tg-1', containerName: 'app', containerPort: 8080}]},
]}
if (command instanceof ListTasksCommand) return {taskArns: ['task-1']}
if (command instanceof DescribeTasksCommand) return {tasks: [{taskArn: 'task-1', lastStatus: 'RUNNING', healthStatus: 'HEALTHY', connectivity: 'CONNECTED'}]}
return {}
}} as unknown as ECSClient
const elb = {async send(command: object) {
if (command instanceof DescribeTargetGroupsCommand) return {TargetGroups: [{TargetType: 'ip', LoadBalancerArns: ['alb-1']}]}
if (command instanceof DescribeTargetHealthCommand) return {TargetHealthDescriptions: [{Target: {Id: '10.0.1.5', Port: 8080}, TargetHealth: {State: 'healthy'}}]}
if (command instanceof DescribeLoadBalancersCommand) return {LoadBalancers: [{DNSName: 'orders.example.test', Scheme: 'internet-facing', State: {Code: 'active'}}]}
return {}
}} as unknown as ElasticLoadBalancingV2Client
const services = await new AwsEcsAdapter(ecs, elb).list()
expect(services).toHaveLength(2)
expect(services[0]).toMatchObject({metadata: {launchType: 'EC2', tasks: [{healthStatus: 'HEALTHY'}]}})
expect(services[1]).toMatchObject({metadata: {launchType: 'FARGATE', networkConfiguration: {subnets: ['subnet-1']}, loadBalancers: [{targetType: 'ip', targets: [{state: 'healthy'}], endpoints: [{dnsName: 'orders.example.test', state: 'active'}]}]}})
})
test('creates an awsvpc Fargate service and force deletes it', async () => {
const sent: object[] = []
const ecs = {async send(command: object) {
sent.push(command)
if (command instanceof CreateServiceCommand) return {service: {serviceName: 'orders', serviceArn: 'arn:aws:ecs:us-east-1:000000000000:service/orders', launchType: 'FARGATE', status: 'ACTIVE', desiredCount: 1, runningCount: 0, taskDefinition: 'orders:1'}}
if (command instanceof ListTasksCommand) return {taskArns: []}
return {}
}} as unknown as ECSClient
const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client
const adapter = new AwsEcsAdapter(ecs, elb)
await adapter.create({values: {cluster, serviceName: 'orders', taskDefinition: 'orders:1', launchType: 'FARGATE', subnets: 'subnet-1, subnet-2', securityGroups: 'sg-1', assignPublicIp: 'ENABLED'}})
await adapter.delete(`${cluster}|orders`)
expect((sent[0] as CreateServiceCommand).input).toMatchObject({cluster, serviceName: 'orders', launchType: 'FARGATE', networkConfiguration: {awsvpcConfiguration: {subnets: ['subnet-1', 'subnet-2'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}}})
expect((sent.at(-1) as DeleteServiceCommand).input).toEqual({cluster, service: 'orders', force: true})
})
test('does not present stale inactive counts as running', async () => {
const ecs = {async send(command: object) {
if (command instanceof DescribeServicesCommand) return {services: [{serviceName: 'orders', status: 'INACTIVE', desiredCount: 2, runningCount: 2, pendingCount: 1, deployments: [{desiredCount: 2, runningCount: 2}]}]}
return {}
}} as unknown as ECSClient
const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client
const service = await new AwsEcsAdapter(ecs, elb).get(`${cluster}|orders`)
expect(service).toMatchObject({metadata: {inactive: true, desiredCount: 0, runningCount: 0, pendingCount: 0, deployments: [{desiredCount: 0, runningCount: 0}]}})
})
test('Fargate adapter lists and creates only Fargate services', async () => {
const sent: object[] = []
const ecs = {async send(command: object) {
sent.push(command)
if (command instanceof ListClustersCommand) return {clusterArns: [cluster]}
if (command instanceof ListServicesCommand) return {serviceArns: ['ec2', 'fargate']}
if (command instanceof DescribeServicesCommand) return {services: [
{serviceName: 'ec2', launchType: 'EC2', status: 'ACTIVE'},
{serviceName: 'fargate', launchType: 'FARGATE', status: 'ACTIVE'},
]}
if (command instanceof CreateServiceCommand) return {service: {serviceName: 'created', launchType: 'FARGATE', status: 'ACTIVE'}}
if (command instanceof ListTasksCommand) return {taskArns: []}
return {}
}} as unknown as ECSClient
const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client
const adapter = new AwsFargateAdapter(new AwsEcsAdapter(ecs, elb))
const services = await adapter.list()
const created = await adapter.create({values: {cluster, serviceName: 'created', taskDefinition: 'created:1', launchType: 'EC2', subnets: 'subnet-1'}})
expect(services).toHaveLength(1)
expect(services[0]).toMatchObject({service: 'fargate', name: 'fargate', metadata: {launchType: 'FARGATE'}})
expect(created).toMatchObject({service: 'fargate', metadata: {launchType: 'FARGATE'}})
expect(sent.find((command) => command instanceof CreateServiceCommand)).toMatchObject({input: {launchType: 'FARGATE'}})
expect(adapter.schema().fields.find((field) => field.name === 'launchType')).toBeUndefined()
expect(adapter.schema().fields.find((field) => field.name === 'subnets')).toMatchObject({required: true})
})
})
@@ -0,0 +1,132 @@
import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient, type Service} from '@aws-sdk/client-ecs'
import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsEcsSchema} from '../cloud-spi/ecsSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsEcsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'ecs' as const
constructor(private readonly client: ECSClient, private readonly elb: ElasticLoadBalancingV2Client) {}
schema(): ServiceSchema { return awsEcsSchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources: CloudResource[] = []
let clusterToken: string | undefined
do {
const clusters = await this.client.send(new ListClustersCommand(clusterToken ? {nextToken: clusterToken} : {}))
for (const cluster of clusters.clusterArns ?? []) {
const services = await this.services(cluster)
resources.push(...await Promise.all(services.map((service) => this.toResource(cluster, service))))
}
clusterToken = clusters.nextToken
} while (clusterToken)
const search = query.search?.trim().toLowerCase()
return resources.filter((resource) => !search || resource.name.toLowerCase().includes(search) || String(resource.metadata.cluster).toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
const [cluster, serviceName] = splitId(id)
if (!cluster || !serviceName) return null
const response = await this.client.send(new DescribeServicesCommand({cluster, services: [serviceName]}))
const service = response.services?.[0]
return service ? this.toResource(cluster, service) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const cluster = required(input.values.cluster, 'cluster')
const serviceName = required(input.values.serviceName, 'serviceName')
const taskDefinition = required(input.values.taskDefinition, 'taskDefinition')
const launchType = oneOf(input.values.launchType, ['EC2', 'FARGATE'], 'launchType') as 'EC2' | 'FARGATE' | undefined ?? 'FARGATE'
const desiredCount = count(input.values.desiredCount)
const subnets = ids(input.values.subnets, 'subnets')
if (launchType === 'FARGATE' && subnets.length === 0) throw new ValidationError('subnets is required for Fargate awsvpc networking')
const securityGroups = ids(input.values.securityGroups, 'securityGroups')
const targetGroupArn = optional(input.values.targetGroupArn, 'targetGroupArn')
const containerName = optional(input.values.containerName, 'containerName')
const containerPort = optionalPort(input.values.containerPort)
if (targetGroupArn && (!containerName || !containerPort)) throw new ValidationError('containerName and containerPort are required with targetGroupArn')
const response = await this.client.send(new CreateServiceCommand({
cluster,
serviceName,
taskDefinition,
desiredCount,
launchType,
...(subnets.length ? {networkConfiguration: {awsvpcConfiguration: {subnets, ...(securityGroups.length ? {securityGroups} : {}), assignPublicIp: input.values.assignPublicIp === true || input.values.assignPublicIp === 'ENABLED' ? 'ENABLED' : 'DISABLED'}}} : {}),
...(targetGroupArn ? {loadBalancers: [{targetGroupArn, containerName, containerPort}]} : {}),
}))
if (!response.service) throw new RuntimeError(`ECS did not return the created service ${serviceName}`)
return this.toResource(cluster, response.service)
}
async delete(id: string): Promise<void> {
const [cluster, serviceName] = splitId(id)
if (!cluster || !serviceName) throw new ValidationError('ECS service id must be cluster|service')
await this.client.send(new DeleteServiceCommand({cluster, service: serviceName, force: true}))
}
private async services(cluster: string): Promise<Service[]> {
const result: Service[] = []
let nextToken: string | undefined
do {
const listed = await this.client.send(new ListServicesCommand(nextToken ? {cluster, nextToken} : {cluster}))
if (listed.serviceArns?.length) {
const described = await this.client.send(new DescribeServicesCommand({cluster, services: listed.serviceArns}))
result.push(...(described.services ?? []))
}
nextToken = listed.nextToken
} while (nextToken)
return result
}
private async toResource(cluster: string, service: Service): Promise<CloudResource> {
const name = service.serviceName ?? service.serviceArn ?? ''
const inactive = service.status === 'INACTIVE'
const [tasks, loadBalancers] = inactive ? [[], []] : await Promise.all([this.tasks(cluster, name), this.loadBalancerHealth(service)])
return {
id: `${cluster}|${name}`, name, cloud: 'aws', service: 'ecs', type: 'ecs-service', region: regionFromArn(service.serviceArn),
createdAt: service.createdAt?.toISOString() ?? null, status: service.status ?? null,
metadata: {
cluster, serviceArn: service.serviceArn, launchType: service.launchType ?? 'EC2', taskDefinition: service.taskDefinition,
desiredCount: inactive ? 0 : service.desiredCount ?? 0, runningCount: inactive ? 0 : service.runningCount ?? 0, pendingCount: inactive ? 0 : service.pendingCount ?? 0,
inactive,
networkConfiguration: service.networkConfiguration?.awsvpcConfiguration ? {subnets: service.networkConfiguration.awsvpcConfiguration.subnets ?? [], securityGroups: service.networkConfiguration.awsvpcConfiguration.securityGroups ?? [], assignPublicIp: service.networkConfiguration.awsvpcConfiguration.assignPublicIp ?? 'DISABLED'} : null,
tasks,
loadBalancers,
deployments: (service.deployments ?? []).map((deployment) => ({id: deployment.id, status: deployment.status, taskDefinition: deployment.taskDefinition, desiredCount: inactive ? 0 : deployment.desiredCount ?? 0, runningCount: inactive ? 0 : deployment.runningCount ?? 0, pendingCount: inactive ? 0 : deployment.pendingCount ?? 0, createdAt: deployment.createdAt?.toISOString(), updatedAt: deployment.updatedAt?.toISOString()})),
},
}
}
private async tasks(cluster: string, serviceName: string): Promise<Array<Record<string, unknown>>> {
const listed = await this.client.send(new ListTasksCommand({cluster, serviceName, desiredStatus: 'RUNNING'}))
if (!listed.taskArns?.length) return []
const described = await this.client.send(new DescribeTasksCommand({cluster, tasks: listed.taskArns}))
return (described.tasks ?? []).map((task) => ({arn: task.taskArn, lastStatus: task.lastStatus, desiredStatus: task.desiredStatus, healthStatus: task.healthStatus, connectivity: task.connectivity, stoppedReason: task.stoppedReason}))
}
private async loadBalancerHealth(service: Service): Promise<Array<Record<string, unknown>>> {
const bindings = await Promise.all((service.loadBalancers ?? []).map(async (binding) => {
if (!binding.targetGroupArn) return []
const group = await this.elb.send(new DescribeTargetGroupsCommand({TargetGroupArns: [binding.targetGroupArn]}))
const targetGroup = group.TargetGroups?.[0]
const targets = await this.elb.send(new DescribeTargetHealthCommand({TargetGroupArn: binding.targetGroupArn}))
const loadBalancerArns = targetGroup?.LoadBalancerArns ?? []
const balancers = loadBalancerArns.length ? await this.elb.send(new DescribeLoadBalancersCommand({LoadBalancerArns: loadBalancerArns})) : {LoadBalancers: []}
return [{targetGroupArn: binding.targetGroupArn, containerName: binding.containerName, containerPort: binding.containerPort, targetType: targetGroup?.TargetType ?? null, targets: (targets.TargetHealthDescriptions ?? []).map((target) => ({id: target.Target?.Id, port: target.Target?.Port, state: target.TargetHealth?.State ?? null, reason: target.TargetHealth?.Reason ?? null})), endpoints: (balancers.LoadBalancers ?? []).map((balancer) => ({arn: balancer.LoadBalancerArn, dnsName: balancer.DNSName, state: balancer.State?.Code ?? null, scheme: balancer.Scheme ?? null}))}]
}))
return bindings.flat()
}
}
function required(value: unknown, field: string): string { const result = optional(value, field); if (!result) throw new ValidationError(`${field} is required`); return result }
function optional(value: unknown, field: string): string | undefined { if (value === undefined || value === null || value === '') return undefined; if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`); return value.trim() || undefined }
function ids(value: unknown, field: string): string[] { const raw = optional(value, field); return raw ? raw.split(',').map((id) => id.trim()).filter(Boolean) : [] }
function count(value: unknown): number { if (value === undefined || value === '') return 1; const result = Number(value); if (!Number.isInteger(result) || result < 0) throw new ValidationError('desiredCount must be a non-negative integer'); return result }
function optionalPort(value: unknown): number | undefined { if (value === undefined || value === '') return undefined; const result = Number(value); if (!Number.isInteger(result) || result < 1 || result > 65535) throw new ValidationError('containerPort must be an integer from 1 to 65535'); return result }
function oneOf(value: unknown, choices: string[], field: string): string | undefined { const result = optional(value, field); if (result && !choices.includes(result)) throw new ValidationError(`${field} must be one of ${choices.join(', ')}`); return result }
function splitId(id: string): [string, string] { const separator = id.indexOf('|'); return separator > 0 && separator < id.length - 1 ? [id.slice(0, separator), id.slice(separator + 1)] : ['', ''] }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
@@ -0,0 +1,39 @@
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
import {ValidationError} from '../cloud-spi/errors'
import {awsFargateSchema} from '../cloud-spi/ecsSchema'
import {AwsEcsAdapter} from './AwsEcsAdapter'
export class AwsFargateAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'fargate' as const
constructor(private readonly ecs: AwsEcsAdapter) {}
schema(): ServiceSchema { return awsFargateSchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources = await this.ecs.list(query)
return resources.filter(isFargate).map(toFargateResource)
}
async get(id: string): Promise<CloudResource | null> {
const resource = await this.ecs.get(id)
return resource && isFargate(resource) ? toFargateResource(resource) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
return toFargateResource(await this.ecs.create({
...input,
values: {...input.values, launchType: 'FARGATE'},
}))
}
async delete(id: string): Promise<void> {
const resource = await this.ecs.get(id)
if (resource && !isFargate(resource)) throw new ValidationError('Fargate service id must identify a FARGATE service')
await this.ecs.delete(id)
}
}
function isFargate(resource: CloudResource): boolean { return resource.metadata.launchType === 'FARGATE' }
function toFargateResource(resource: CloudResource): CloudResource { return {...resource, service: 'fargate'} }