feat: Fargate
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@aws-sdk/client-dynamodb": "^3.1088.0",
|
||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||
"@aws-sdk/client-ecs": "^3.1076.0",
|
||||
"@aws-sdk/client-eks": "^3.1076.0",
|
||||
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
|
||||
"@aws-sdk/client-eventbridge": "^3.1076.0",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ export interface PipelineState {
|
||||
name: string;
|
||||
status?: string;
|
||||
lastStatusChange?: string;
|
||||
actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string}>;
|
||||
actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string; codeDeployApplication?: string; codeDeployDeploymentGroup?: string}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -960,7 +960,7 @@ export async function stopWorkflowExecution(stateMachineArn: string, executionAr
|
||||
);
|
||||
}
|
||||
|
||||
export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]}): Promise<CodeBuildBuild> {
|
||||
export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]; buildspecOverride?: string}): Promise<CodeBuildBuild> {
|
||||
const res = await apiClient.call<CodeBuildBuild, typeof input>(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -9,28 +9,34 @@ interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: bool
|
||||
|
||||
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
|
||||
const client = useQueryClient()
|
||||
const projectSourceType = sourceType(resource)
|
||||
const [startOpen, setStartOpen] = useState(false)
|
||||
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
|
||||
const [buildspecOverride, setBuildspecOverride] = useState(() => defaultBuildspec(projectSourceType))
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const enabled = Boolean(resource && runtimeReachable)
|
||||
const buildsKey = ['codebuild-builds', resource?.id]
|
||||
const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false})
|
||||
const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false})
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]) }, [resource?.id])
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(DEFAULT_BUILDSPEC); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
|
||||
if (!resource) return null
|
||||
const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined
|
||||
const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Builds</p><h3>Run and inspect builds</h3></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || builds.isFetching} onClick={() => builds.refetch()}><RefreshCw size={13}/>{builds.isFetching ? 'Refreshing' : 'Refresh'}</button><button className="button primary" type="button" disabled={!enabled} onClick={() => setStartOpen(true)}><Play size={13}/>Start build</button></div></div>
|
||||
{startOpen && <StartBuildDialog overrides={overrides} pending={start.isPending} error={start.error} onChange={setOverrides} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>}
|
||||
{startOpen && (
|
||||
<StartBuildDialog overrides={overrides} buildspecOverride={buildspecOverride} requiresBuildspec={projectSourceType === 'NO_SOURCE'} pending={start.isPending} error={start.error} onChange={setOverrides} onBuildspecChange={setBuildspecOverride} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>
|
||||
)}
|
||||
{ecrRepository && <p className="muted compact-text">Docker builds push to ECR repository <a href={`/cloud-explorer/aws/ecr?resource=${encodeURIComponent(ecrRepository)}`}>{ecrRepository}</a> and publish `imageDetail.json`, `taskdef.json`, and `appspec.yaml` for CodeDeployToECS.</p>}
|
||||
{builds.isError && <p className="error-text compact-text">{message(builds.error, 'Unable to load builds.')}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Build history</h4><span>{builds.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Build</th><th>Status</th><th>Phase</th><th>Started</th></tr></thead><tbody>{(builds.data ?? []).map((build) => <tr key={build.id} onClick={() => setSelectedId(build.id)} style={{cursor: 'pointer'}}><td><code>#{build.buildNumber ?? build.id}</code></td><td>{build.buildStatus ?? '-'}</td><td>{build.currentPhase ?? '-'}</td><td>{formatDateTime(build.startTime) ?? '-'}</td></tr>)}</tbody></table></div>{!builds.isLoading && builds.data?.length === 0 && <p className="muted compact-text">No builds yet.</p>}</div>
|
||||
{selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) {
|
||||
return <div className="workflow-confirmation" role="dialog" aria-labelledby="start-build-title"><div className="dynamic-stage-header"><div><strong id="start-build-title">Start build</strong><p className="muted compact-text">Environment variable overrides apply only to this build.</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div>{overrides.map((override, index) => <div className="input-row" key={index}><input className="input" aria-label={`Variable name ${index + 1}`} placeholder="Name" value={override.name} onChange={(event) => onChange(replace(overrides, index, {...override, name: event.target.value}))}/><input className="input" aria-label={`Variable value ${index + 1}`} placeholder="Value" value={override.value} onChange={(event) => onChange(replace(overrides, index, {...override, value: event.target.value}))}/><button className="button compact" type="button" disabled={pending} onClick={() => onChange(overrides.filter((_, item) => item !== index))}>Remove</button></div>)}<button className="button compact" type="button" disabled={pending} onClick={() => onChange([...overrides, {name: '', value: '', type: 'PLAINTEXT'}])}><Plus size={13}/>Add override</button>{Boolean(error) && <p className="error-text compact-text">{message(error, 'Unable to start build.')}</p>}<div><button className="button primary compact" type="button" disabled={pending || overrides.some((item) => !item.name.trim())} onClick={onStart}>{pending ? 'Starting' : 'Start build'}</button><button className="button compact" type="button" disabled={pending} onClick={onCancel}>Cancel</button></div></div>
|
||||
function StartBuildDialog({overrides, buildspecOverride, requiresBuildspec, pending, error, onChange, onBuildspecChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; buildspecOverride: string; requiresBuildspec: boolean; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onBuildspecChange: (value: string) => void; onCancel: () => void; onStart: () => void}) {
|
||||
return <div className="workflow-confirmation" role="dialog" aria-labelledby="start-build-title"><div className="dynamic-stage-header"><div><strong id="start-build-title">Start build</strong><p className="muted compact-text">{requiresBuildspec ? 'A buildspec override lets this no-source project run a real build.' : 'Leave the buildspec override blank to use the S3 source buildspec.'}</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div><label className="dynamic-field"><span>Buildspec override</span><textarea className="textarea code-textarea small" value={buildspecOverride} onChange={(event) => onBuildspecChange(event.target.value)} rows={8} spellCheck={false}/></label>{overrides.map((override, index) => <div className="input-row" key={index}><input className="input" aria-label={`Variable name ${index + 1}`} placeholder="Name" value={override.name} onChange={(event) => onChange(replace(overrides, index, {...override, name: event.target.value}))}/><input className="input" aria-label={`Variable value ${index + 1}`} placeholder="Value" value={override.value} onChange={(event) => onChange(replace(overrides, index, {...override, value: event.target.value}))}/><button className="button compact" type="button" disabled={pending} onClick={() => onChange(overrides.filter((_, item) => item !== index))}>Remove</button></div>)}<button className="button compact" type="button" disabled={pending} onClick={() => onChange([...overrides, {name: '', value: '', type: 'PLAINTEXT'}])}><Plus size={13}/>Add override</button>{Boolean(error) && <p className="error-text compact-text">{message(error, 'Unable to start build.')}</p>}<div><button className="button primary compact" type="button" disabled={pending || (requiresBuildspec && !buildspecOverride.trim()) || overrides.some((item) => !item.name.trim())} onClick={onStart}>{pending ? 'Starting' : 'Start build'}</button><button className="button compact" type="button" disabled={pending} onClick={onCancel}>Cancel</button></div></div>
|
||||
}
|
||||
|
||||
function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: boolean; error: unknown}) {
|
||||
@@ -42,5 +48,8 @@ function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading:
|
||||
}
|
||||
|
||||
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
|
||||
const DEFAULT_BUILDSPEC = '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'
|
||||
function sourceType(resource?: CloudResource): string | undefined { return typeof resource?.metadata.sourceType === 'string' ? resource.metadata.sourceType : undefined }
|
||||
function defaultBuildspec(sourceType?: string): string { return sourceType === 'S3' ? '' : DEFAULT_BUILDSPEC }
|
||||
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
|
||||
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
|
||||
|
||||
@@ -41,9 +41,10 @@ export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudR
|
||||
const stop = useMutation({mutationFn: ({id, rollback}: {id: string; rollback: boolean}) => stopCodeDeployDeployment(id, rollback), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
|
||||
const selected = dependencies.data?.lambdaFunctions.find((item) => item.name === functionName)
|
||||
if (!resource) return null
|
||||
const ecsServices = Array.isArray(resource.metadata.ecsServices) ? resource.metadata.ecsServices as Array<{clusterName?: string; serviceName?: string}> : []
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda release</p><h3>Deploy a version to an alias</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{ecsServices.length ? 'ECS EC2 release' : 'Lambda release'}</p><h3>{ecsServices.length ? 'CodeDeploy deployment status' : 'Deploy a version to an alias'}</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}{ecsServices.length ? ` | ${ecsServices.map((service) => `${service.clusterName}/${service.serviceName}`).join(', ')}` : ''}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
{!ecsServices.length && <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
|
||||
<label>Function<select className="input" required value={functionName} onChange={(event) => { setFunctionName(event.target.value); setAlias(''); setCurrentVersion(''); setTargetVersion('') }}><option value="">Select Lambda function</option>{(dependencies.data?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
|
||||
<label>Alias<select className="input" required value={alias} onChange={(event) => setAlias(event.target.value)} disabled={!selected}><option value="">Select alias</option>{selected?.aliases.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
<label>Current version<select className="input" required value={currentVersion} onChange={(event) => setCurrentVersion(event.target.value)} disabled={!selected}><option value="">Select current version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
@@ -52,7 +53,7 @@ export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudR
|
||||
<label>AfterAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={afterHook} onChange={setAfterHook}/></label>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error)}</p>}
|
||||
<div className="form-actions"><button className="button primary" type="submit" disabled={!enabled || start.isPending}><Play size={13}/>{start.isPending ? 'Starting' : 'Start deployment'}</button></div>
|
||||
</form>
|
||||
</form>}
|
||||
{deployments.isError && <p className="error-text compact-text">{message(deployments.error)}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Deployment group history</h4><span>{deployments.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Traffic</th><th>Versions</th><th>Started</th><th>Actions</th></tr></thead><tbody>{(deployments.data ?? []).map((deployment) => <tr key={deployment.id}><td><code>{deployment.id}</code>{deployment.errorInformation?.message && <p className="error-text compact-text">{deployment.errorInformation.message}</p>}</td><td>{deployment.status ?? '-'}</td><td>{deployment.trafficPercent === null ? '-' : `${deployment.trafficPercent}%`}</td><td>{deployment.currentVersion ?? '-'} to {deployment.targetVersion ?? '-'}</td><td>{formatDateTime(deployment.createTime) ?? '-'}</td><td>{active(deployment.status) && <><button className="button compact danger" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: false})}><Ban size={12}/>Stop</button><button className="button compact" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: true})}><RotateCcw size={12}/>Stop and roll back</button></>}</td></tr>)}</tbody></table></div>{!deployments.isLoading && deployments.data?.length === 0 && <p className="muted compact-text">No deployments yet.</p>}</div>
|
||||
</section>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePane
|
||||
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
|
||||
if (!resource) return null
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and S3 deploy.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and CodeDeployToECS for ECS EC2 services.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start pipeline execution.')}</p>}
|
||||
{(executions.isError || state.isError) && <p className="error-text compact-text">{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Execution history</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Execution</th><th>Status</th><th>Started</th><th>Updated</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.id}><td><code>{execution.id}</code>{execution.statusSummary && <p className="muted compact-text">{execution.statusSummary}</p>}</td><td>{execution.status ?? '-'}</td><td>{formatDateTime(execution.startTime) ?? '-'}</td><td>{formatDateTime(execution.lastUpdateTime) ?? '-'}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</div>
|
||||
@@ -32,7 +32,7 @@ function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boo
|
||||
function ActionLinks({action}: {action: PipelineState['stages'][number]['actions'][number]}) {
|
||||
const links = [
|
||||
action.codeBuildProject ? {href: `/cloud-explorer/aws/codebuild?resource=${encodeURIComponent(action.codeBuildProject)}`, label: 'Open CodeBuild'} : undefined,
|
||||
action.codeDeployDeploymentId ? {href: `/cloud-explorer/aws/codedeploy?deployment=${encodeURIComponent(action.codeDeployDeploymentId)}`, label: 'Open CodeDeploy'} : undefined,
|
||||
action.codeDeployApplication && action.codeDeployDeploymentGroup ? {href: `/cloud-explorer/aws/codedeploy?resource=${encodeURIComponent(`${action.codeDeployApplication}/${action.codeDeployDeploymentGroup}`)}`, label: 'Open CodeDeploy'} : undefined,
|
||||
action.externalExecutionUrl ? {href: action.externalExecutionUrl, label: 'Execution details'} : undefined,
|
||||
action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined,
|
||||
].filter((item): item is {href: string; label: string} => Boolean(item))
|
||||
|
||||
@@ -65,6 +65,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
import { EcsServicePanel } from "@/components/EcsServicePanel";
|
||||
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
|
||||
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||
@@ -1012,6 +1013,7 @@ export function DynamicResourceView({
|
||||
{service === "codebuild" && cloud === "aws" && (
|
||||
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{(service === "ecs" || service === "fargate") && cloud === "aws" && <EcsServicePanel resource={activeSelected} />}
|
||||
{service === "codepipeline" && cloud === "aws" && (
|
||||
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {expect, test} from 'vitest'
|
||||
import {EcsServicePanel} from './EcsServicePanel'
|
||||
|
||||
test('shows Fargate awsvpc task, IP target, and ALB endpoint health', () => {
|
||||
render(<EcsServicePanel resource={{id: 'orders-cluster|orders', name: 'orders', cloud: 'aws', service: 'ecs', type: 'ecs-service', region: 'us-east-1', createdAt: null, metadata: {cluster: 'orders-cluster', launchType: 'FARGATE', desiredCount: 1, runningCount: 1, networkConfiguration: {subnets: ['subnet-1'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}, tasks: [{arn: 'task-1', lastStatus: 'RUNNING', healthStatus: 'HEALTHY', connectivity: 'CONNECTED'}], loadBalancers: [{targetGroupArn: 'tg-1', targetType: 'ip', targets: [{id: '10.0.1.5', port: 8080, state: 'healthy'}], endpoints: [{dnsName: 'orders.example.test', scheme: 'internet-facing', state: 'active'}]}]}}}/>)
|
||||
|
||||
expect(screen.getByText('Fargate service state')).toBeInTheDocument()
|
||||
expect(screen.getByText('awsvpc networking: subnets subnet-1 | security groups sg-1 | public IP ENABLED')).toBeInTheDocument()
|
||||
expect(screen.getByText('HEALTHY')).toBeInTheDocument()
|
||||
expect(screen.getByText('orders.example.test')).toBeInTheDocument()
|
||||
expect(screen.getByText('10.0.1.5:8080')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('labels inactive services as not running instead of showing stale counts', () => {
|
||||
render(<EcsServicePanel resource={{id: 'orders-cluster|orders', name: 'orders', cloud: 'aws', service: 'ecs', type: 'ecs-service', region: 'us-east-1', createdAt: null, status: 'INACTIVE', metadata: {cluster: 'orders-cluster', launchType: 'FARGATE', desiredCount: 2, runningCount: 2, inactive: true}}}/>)
|
||||
|
||||
expect(screen.getByText('Inactive, not running')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Running 2 of 2')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
|
||||
interface EcsServicePanelProps { resource?: CloudResource }
|
||||
interface Deployment { id?: string; status?: string; taskDefinition?: string; desiredCount?: number; runningCount?: number; pendingCount?: number; createdAt?: string; updatedAt?: string }
|
||||
interface Task { arn?: string; lastStatus?: string; desiredStatus?: string; healthStatus?: string; connectivity?: string; stoppedReason?: string }
|
||||
interface Target { id?: string; port?: number; state?: string; reason?: string }
|
||||
interface LoadBalancer { targetGroupArn?: string; targetType?: string | null; containerName?: string; containerPort?: number; targets?: Target[]; endpoints?: Array<{dnsName?: string; state?: string | null; scheme?: string | null}> }
|
||||
|
||||
export function EcsServicePanel({resource}: EcsServicePanelProps) {
|
||||
if (!resource) return null
|
||||
const deployments = array<Deployment>(resource.metadata.deployments)
|
||||
const tasks = array<Task>(resource.metadata.tasks)
|
||||
const loadBalancers = array<LoadBalancer>(resource.metadata.loadBalancers)
|
||||
const inactive = resource.status === 'INACTIVE' || resource.metadata.inactive === true
|
||||
const launchType = String(resource.metadata.launchType ?? 'EC2')
|
||||
const network = object(resource.metadata.networkConfiguration)
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{launchType === 'FARGATE' ? 'Fargate service state' : 'EC2 service state'}</p><h3>{resource.name}</h3><p className="muted compact-text">Launch type: <strong>{launchType}</strong> | Cluster: {String(resource.metadata.cluster ?? '-')} | <span>{inactive ? 'Inactive, not running' : `Running ${String(resource.metadata.runningCount ?? 0)} of ${String(resource.metadata.desiredCount ?? 0)}`}</span></p></div></div>
|
||||
{network && <p className="muted compact-text">awsvpc networking: subnets {values(network.subnets)} | security groups {values(network.securityGroups)} | public IP {String(network.assignPublicIp ?? 'DISABLED')}</p>}
|
||||
{launchType === 'FARGATE' && !network && <p className="muted compact-text">Fargate services require awsvpc networking. Attach an IP target group, not an instance target group, when using an ALB.</p>}
|
||||
{inactive ? <p className="muted compact-text">This service is inactive. ECS may retain historical deployment counts while deletion settles; they are not shown as running tasks.</p> : <>
|
||||
<h4>Tasks</h4>
|
||||
{tasks.length ? <div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Task</th><th>Last status</th><th>Health</th><th>Connectivity</th></tr></thead><tbody>{tasks.map((task, index) => <tr key={task.arn ?? index}><td><code>{task.arn ?? '-'}</code></td><td>{task.lastStatus ?? '-'}</td><td>{task.healthStatus ?? '-'}</td><td>{task.connectivity ?? '-'}</td></tr>)}</tbody></table></div> : <p className="muted compact-text">No running tasks reported for this service.</p>}
|
||||
{loadBalancers.map((loadBalancer, index) => <div key={loadBalancer.targetGroupArn ?? index}><h4>ALB and target health</h4><p className="muted compact-text">Target group: <code>{loadBalancer.targetGroupArn ?? '-'}</code> | Target type: <strong>{loadBalancer.targetType ?? '-'}</strong>{launchType === 'FARGATE' && loadBalancer.targetType !== 'ip' ? ' (Fargate requires IP targets)' : ''}</p>{loadBalancer.endpoints?.map((endpoint, endpointIndex) => <p className="muted compact-text" key={endpoint.dnsName ?? endpointIndex}>ALB endpoint: <code>{endpoint.dnsName ?? '-'}</code> | {endpoint.scheme ?? '-'} | {endpoint.state ?? '-'}</p>)}<div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Target</th><th>Health</th><th>Reason</th></tr></thead><tbody>{(loadBalancer.targets ?? []).map((target, targetIndex) => <tr key={`${target.id ?? targetIndex}:${target.port ?? ''}`}><td><code>{target.id ?? '-'}{target.port ? `:${target.port}` : ''}</code></td><td>{target.state ?? '-'}</td><td>{target.reason ?? '-'}</td></tr>)}</tbody></table></div>{!loadBalancer.targets?.length && <p className="muted compact-text">No target health reported by this target group.</p>}</div>)}
|
||||
<h4>Deployments</h4>
|
||||
<div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Task definition</th><th>Running</th><th>Updated</th></tr></thead><tbody>{deployments.map((deployment, index) => <tr key={deployment.id ?? index}><td><code>{deployment.id ?? '-'}</code></td><td>{deployment.status ?? '-'}</td><td><code>{deployment.taskDefinition ?? '-'}</code></td><td>{deployment.runningCount ?? 0} / {deployment.desiredCount ?? 0}{deployment.pendingCount ? ` (${deployment.pendingCount} pending)` : ''}</td><td>{formatDateTime(deployment.updatedAt ?? deployment.createdAt) ?? '-'}</td></tr>)}</tbody></table></div>
|
||||
{deployments.length === 0 && <p className="muted compact-text">No deployment state reported for this service.</p>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function array<T>(value: unknown): T[] { return Array.isArray(value) ? value as T[] : [] }
|
||||
function object(value: unknown): Record<string, unknown> | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null }
|
||||
function values(value: unknown): string { return Array.isArray(value) && value.length ? value.map(String).join(', ') : '-' }
|
||||
Generated
+18
@@ -40,6 +40,9 @@ importers:
|
||||
'@aws-sdk/client-ecr':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
'@aws-sdk/client-ecs':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1145.0
|
||||
'@aws-sdk/client-eks':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
@@ -293,6 +296,10 @@ packages:
|
||||
resolution: {integrity: sha512-lZISXKHUDSMMZJonhKeV2h5dWMP2lXwLfhitqdgEE+FziHq4Jx7iW1Vhzx3kJxMMKTisqTADbgO0UCJSd2OxwA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-ecs@3.1145.0':
|
||||
resolution: {integrity: sha512-r3Kfyv+ax47DcXUKgMV/n9xUhKL1KjDBo3HC6+4CL+lOWXXYXsEJIWpQrDZYVwX8nbxuD7TXVFfEeY/38dmD1Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-eks@3.1076.0':
|
||||
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2811,6 +2818,17 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-ecs@3.1145.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-eks@3.1076.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
|
||||
Reference in New Issue
Block a user