feat: Fargate
This commit is contained in:
@@ -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'} }
|
||||
@@ -22,6 +22,7 @@ import { ECRClient } from "@aws-sdk/client-ecr";
|
||||
import { CodeBuildClient } from "@aws-sdk/client-codebuild";
|
||||
import { CodePipelineClient } from "@aws-sdk/client-codepipeline";
|
||||
import { CodeDeployClient } from "@aws-sdk/client-codedeploy";
|
||||
import { ECSClient } from "@aws-sdk/client-ecs";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -75,6 +76,7 @@ export type AwsClients = {
|
||||
codebuild: CodeBuildClient;
|
||||
codepipeline: CodePipelineClient;
|
||||
codedeploy: CodeDeployClient;
|
||||
ecs: ECSClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -112,6 +114,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
codebuild: new CodeBuildClient(base),
|
||||
codepipeline: new CodePipelineClient(base),
|
||||
codedeploy: new CodeDeployClient(base),
|
||||
ecs: new ECSClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface CodeBuildEnvironmentVariableOverride {
|
||||
|
||||
export interface StartCodeBuildInput {
|
||||
environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]
|
||||
buildspecOverride?: string
|
||||
}
|
||||
|
||||
export interface CodeBuildPhase {
|
||||
|
||||
@@ -19,14 +19,57 @@ export function awsCodeBuildSchema(): ServiceSchema {
|
||||
{name: 'name', label: 'Project name', type: 'text', required: true, group: 'Required'},
|
||||
{name: 'serviceRole', label: 'Service role ARN', type: 'text', required: true, group: 'Required'},
|
||||
{
|
||||
name: 'environmentImage', label: 'Environment image', type: 'text', required: false, group: 'Environment',
|
||||
name: 'sourceType', label: 'Source type', type: 'select', required: true, group: 'Source', defaultValue: 'NO_SOURCE',
|
||||
options: [{label: 'No source', value: 'NO_SOURCE'}, {label: 'Amazon S3', value: 'S3'}],
|
||||
description: 'S3 source is supported locally. GitHub and Gitea sources are not executed by the local runner.',
|
||||
},
|
||||
{
|
||||
name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: false, requiredWhen: {field: 'sourceType', equals: 'S3'}, group: 'Source',
|
||||
validation: {pattern: '^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$', message: 'Use a valid 3-63 character S3 bucket name.'},
|
||||
},
|
||||
{
|
||||
name: 'sourceKey', label: 'Source object key', type: 'text', required: false, requiredWhen: {field: 'sourceType', equals: 'S3'}, group: 'Source',
|
||||
description: 'The ZIP source object key in the source bucket.',
|
||||
},
|
||||
{
|
||||
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
|
||||
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: 'version: 0.2\nphases:\n pre_build:\n commands:\n - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)\n - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY\n - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\n build:\n commands:\n - IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}\n - docker build -t $REPOSITORY_URI:$IMAGE_TAG .\n - docker push $REPOSITORY_URI:$IMAGE_TAG\n post_build:\n commands:\n - printf \'[{"name":"IMAGE1_NAME","imageUri":"%s"}]\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml', S3: ''}},
|
||||
description: 'For no source, this is a Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override.',
|
||||
},
|
||||
{
|
||||
name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS',
|
||||
options: [{label: 'No artifacts', value: 'NO_ARTIFACTS'}, {label: 'Amazon S3', value: 'S3'}],
|
||||
},
|
||||
{
|
||||
name: 'artifactsBucket', label: 'Artifact S3 bucket', type: 'text', required: false, requiredWhen: {field: 'artifactsType', equals: 'S3'}, group: 'Artifacts',
|
||||
validation: {pattern: '^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$', message: 'Use a valid 3-63 character S3 bucket name.'},
|
||||
},
|
||||
{
|
||||
name: 'artifactsPrefix', label: 'Artifact prefix', type: 'text', required: false, group: 'Artifacts',
|
||||
description: 'Optional S3 key prefix for build artifacts.',
|
||||
},
|
||||
{
|
||||
name: 'environmentImage', label: 'Runtime image', type: 'select', required: true, group: 'Environment',
|
||||
defaultValue: 'aws/codebuild/standard:7.0',
|
||||
description: 'AWS CodeBuild managed image identifier.',
|
||||
options: [
|
||||
{label: 'CodeBuild Standard 7.0', value: 'aws/codebuild/standard:7.0'},
|
||||
{label: 'Node.js 20 (Debian)', value: 'node:20-bookworm'},
|
||||
],
|
||||
description: 'Choose the container runtime for your build commands.',
|
||||
},
|
||||
{
|
||||
name: 'computeType', label: 'Compute type', type: 'select', required: true, group: 'Environment', defaultValue: 'BUILD_GENERAL1_SMALL',
|
||||
options: [{label: 'General 1 small', value: 'BUILD_GENERAL1_SMALL'}],
|
||||
},
|
||||
{
|
||||
name: 'ecrRepository', label: 'ECR repository', type: 'text', required: true, group: 'Docker and ECR',
|
||||
description: 'An existing ECR repository. The project exports it as ECR_REPOSITORY for the Docker buildspec.',
|
||||
},
|
||||
{
|
||||
name: 'dockerPrivilegedMode', label: 'Docker daemon', type: 'select', required: true, group: 'Docker and ECR', defaultValue: 'enabled',
|
||||
options: [{label: 'Enabled (required for Docker builds)', value: 'enabled'}],
|
||||
description: 'CodeBuild runs Docker-in-Docker in privileged mode. This console flow targets ECS EC2 only.',
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface PipelineActionState {
|
||||
revisionUrl?: string
|
||||
codeBuildProject?: string
|
||||
codeDeployDeploymentId?: string
|
||||
codeDeployApplication?: string
|
||||
codeDeployDeploymentGroup?: string
|
||||
}
|
||||
|
||||
export interface PipelineStageState {
|
||||
|
||||
@@ -18,8 +18,11 @@ export function awsCodePipelineSchema(): ServiceSchema {
|
||||
{name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: true, group: 'Source'},
|
||||
{name: 'sourceKey', label: 'Source object key', type: 'text', required: true, group: 'Source'},
|
||||
{name: 'codeBuildProject', label: 'CodeBuild project', type: 'text', required: true, group: 'Build'},
|
||||
{name: 'deployBucket', label: 'Deploy S3 bucket', type: 'text', required: true, group: 'Deploy'},
|
||||
{name: 'deployKey', label: 'Deploy object key', type: 'text', required: true, group: 'Deploy'},
|
||||
{name: 'codeDeployApplication', label: 'CodeDeploy application', type: 'text', required: true, group: 'ECS deploy'},
|
||||
{name: 'codeDeployDeploymentGroup', label: 'CodeDeploy deployment group', type: 'text', required: true, group: 'ECS deploy'},
|
||||
{name: 'taskDefinitionPath', label: 'Task definition path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'taskdef.json'},
|
||||
{name: 'appSpecPath', label: 'AppSpec path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'appspec.yaml'},
|
||||
{name: 'imageContainerName', label: 'Task definition image placeholder', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'IMAGE1_NAME', description: 'The placeholder in taskdef.json, for example <IMAGE1_NAME>. CodeBuild supplies the ECR image from BuildOutput. The CodeDeploy deployment group must target an ECS EC2 service.'},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {resourceActions: [
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
const columns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Service'},
|
||||
{name: 'status', label: 'Status', format: 'badge'},
|
||||
{name: 'launchType', label: 'Launch type', path: 'metadata.launchType', format: 'badge'},
|
||||
{name: 'cluster', label: 'Cluster', path: 'metadata.cluster', format: 'code'},
|
||||
{name: 'taskDefinition', label: 'Task definition', path: 'metadata.taskDefinition', format: 'code'},
|
||||
{name: 'runningCount', label: 'Running', path: 'metadata.runningCount'},
|
||||
{name: 'desiredCount', label: 'Desired', path: 'metadata.desiredCount'},
|
||||
]
|
||||
|
||||
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsEcsSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws', service: 'ecs', displayName: 'Amazon ECS',
|
||||
fields: [
|
||||
{name: 'cluster', label: 'Cluster', type: 'text', required: true, group: 'Required'},
|
||||
{name: 'serviceName', label: 'Service name', type: 'text', required: true, group: 'Required'},
|
||||
{name: 'taskDefinition', label: 'Task definition', type: 'text', required: true, group: 'Required'},
|
||||
{name: 'launchType', label: 'Launch type', type: 'select', required: true, defaultValue: 'FARGATE', group: 'Required', options: [{label: 'Fargate', value: 'FARGATE'}, {label: 'EC2', value: 'EC2'}]},
|
||||
{name: 'desiredCount', label: 'Desired tasks', type: 'text', required: false, defaultValue: '1', group: 'Required'},
|
||||
{name: 'subnets', label: 'Subnet IDs', type: 'text', required: false, requiredWhen: {field: 'launchType', equals: 'FARGATE'}, group: 'awsvpc networking', span: true, description: 'Required for Fargate. Comma-separated subnets are used for awsvpc task ENIs.'},
|
||||
{name: 'securityGroups', label: 'Security group IDs', type: 'text', required: false, group: 'awsvpc networking', span: true, description: 'Optional comma-separated groups for awsvpc tasks.'},
|
||||
{name: 'assignPublicIp', label: 'Assign public IP', type: 'select', required: false, defaultValue: 'DISABLED', group: 'awsvpc networking', options: [{label: 'Disabled', value: 'DISABLED'}, {label: 'Enabled', value: 'ENABLED'}]},
|
||||
{name: 'targetGroupArn', label: 'ALB target group ARN', type: 'text', required: false, group: 'Load balancing', span: true, description: 'Fargate awsvpc services must use an IP target group. The target health and attached ALB endpoint are shown after creation.'},
|
||||
{name: 'containerName', label: 'Container name', type: 'text', required: false, group: 'Load balancing'},
|
||||
{name: 'containerPort', label: 'Container port', type: 'text', required: false, group: 'Load balancing'},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {resourceActions: [
|
||||
{name: 'list', label: 'List ECS services', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect service health', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create EC2 or Fargate service', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Force delete service', enabled: true, status: 'available', runtimeRequired: true},
|
||||
]},
|
||||
filters, columns,
|
||||
}
|
||||
}
|
||||
|
||||
export function awsFargateSchema(): ServiceSchema {
|
||||
const ecs = awsEcsSchema()
|
||||
return {
|
||||
...ecs,
|
||||
service: 'fargate',
|
||||
displayName: 'AWS Fargate',
|
||||
fields: ecs.fields
|
||||
.filter((field) => field.name !== 'launchType')
|
||||
.map((field) => field.name === 'subnets' ? {
|
||||
...field,
|
||||
required: true,
|
||||
requiredWhen: undefined,
|
||||
description: 'Required for Fargate awsvpc task ENIs. Comma-separated subnets are used for awsvpc networking.',
|
||||
} : field),
|
||||
capabilities: {
|
||||
resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? {
|
||||
...action,
|
||||
label: 'Create Fargate service',
|
||||
} : action),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,18 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Compute',
|
||||
order: 40,
|
||||
},
|
||||
ecs: {
|
||||
displayName: 'ECS',
|
||||
iconKey: 'containers',
|
||||
group: 'Compute',
|
||||
order: 50,
|
||||
},
|
||||
fargate: {
|
||||
displayName: 'Fargate',
|
||||
iconKey: 'containers',
|
||||
group: 'Compute',
|
||||
order: 51,
|
||||
},
|
||||
codebuild: {
|
||||
displayName: 'CodeBuild',
|
||||
iconKey: 'codebuild',
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface FieldSchema {
|
||||
label: string
|
||||
type: FieldType
|
||||
required: boolean
|
||||
requiredWhen?: {field: string; equals: string}
|
||||
description?: string
|
||||
group?: string
|
||||
span?: boolean
|
||||
|
||||
@@ -42,6 +42,8 @@ import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter'
|
||||
import {AwsCodeBuildAdapter} from './adapter-aws/AwsCodeBuildAdapter'
|
||||
import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter'
|
||||
import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
|
||||
import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
|
||||
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -86,6 +88,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsCodeBuildAdapter(clients.codebuild),
|
||||
new AwsCodePipelineAdapter(clients.codepipeline),
|
||||
new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda),
|
||||
new AwsEcsAdapter(clients.ecs, clients.elbv2),
|
||||
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -866,12 +866,18 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
app.post('/:cloud/services/codebuild/resources/:id/builds', async (c) => {
|
||||
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const body = await jsonBody<{environmentVariablesOverride?: unknown}>(c)
|
||||
const body = await jsonBody<{environmentVariablesOverride?: unknown; buildspecOverride?: unknown}>(c)
|
||||
const overrides = body.environmentVariablesOverride
|
||||
if (overrides !== undefined && (!Array.isArray(overrides) || overrides.some((item) => !item || typeof item !== 'object' || typeof (item as {name?: unknown}).name !== 'string' || typeof (item as {value?: unknown}).value !== 'string' || ((item as {type?: unknown}).type !== undefined && !['PLAINTEXT', 'PARAMETER_STORE', 'SECRETS_MANAGER'].includes((item as {type: string}).type))))) {
|
||||
throw new ValidationError('environmentVariablesOverride must contain name, value, and an optional supported type')
|
||||
}
|
||||
return c.json(await svc(c).startCodeBuild('aws', c.req.param('id'), {environmentVariablesOverride: overrides as never}), 201)
|
||||
if (body.buildspecOverride !== undefined && typeof body.buildspecOverride !== 'string') {
|
||||
throw new ValidationError('buildspecOverride must be a string')
|
||||
}
|
||||
const buildspecOverride = typeof body.buildspecOverride === 'string' && body.buildspecOverride.trim()
|
||||
? body.buildspecOverride
|
||||
: undefined
|
||||
return c.json(await svc(c).startCodeBuild('aws', c.req.param('id'), {environmentVariablesOverride: overrides as never, buildspecOverride}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user