feat: Fargate

This commit is contained in:
Zakaria
2026-10-02 09:53:45 -04:00
parent 7f08f5b668
commit 2d8ff50a99
27 changed files with 574 additions and 35 deletions
+1
View File
@@ -20,6 +20,7 @@
"@aws-sdk/client-dynamodb": "^3.1088.0", "@aws-sdk/client-dynamodb": "^3.1088.0",
"@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-ecr": "^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-eks": "^3.1076.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-eventbridge": "^3.1076.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'}) 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} : {}) const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {})
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole}}) 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'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}}) 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 () => { 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 image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0'
const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL 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') 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({ const response = await this.client.send(new CreateProjectCommand({
name, name,
serviceRole, serviceRole,
source: {type: 'NO_SOURCE'}, source,
artifacts: {type: 'NO_ARTIFACTS'}, artifacts,
environment: {type: 'LINUX_CONTAINER', image, computeType}, 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}`) if (!response.project) throw new RuntimeError(`CodeBuild did not return project ${name}`)
return toResource(response.project) return toResource(response.project)
@@ -69,6 +80,7 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter {
const response = await this.client.send(new StartBuildCommand({ const response = await this.client.send(new StartBuildCommand({
projectName, projectName,
environmentVariablesOverride: input.environmentVariablesOverride, environmentVariablesOverride: input.environmentVariablesOverride,
buildspecOverride: input.buildspecOverride,
})) }))
if (!response.build) throw new RuntimeError(`CodeBuild did not return a build for ${projectName}`) if (!response.build) throw new RuntimeError(`CodeBuild did not return a build for ${projectName}`)
return toBuild(response.build) return toBuild(response.build)
@@ -122,6 +134,8 @@ function toResource(project: Project): CloudResource {
environmentType: project.environment?.type, environmentType: project.environment?.type,
environmentImage: project.environment?.image, environmentImage: project.environment?.image,
computeType: project.environment?.computeType, 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 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 { function regionFromArn(arn?: string): string | null {
return arn?.split(':')[3] || null return arn?.split(':')[3] || null
} }
@@ -117,7 +117,7 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter {
const group = response.deploymentGroupInfo const group = response.deploymentGroupInfo
if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`) if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`)
const configuration = group.deploymentConfigName 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', () => { 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 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} : {}) 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 const stages = (sent[0] as CreatePipelineCommand).input.pipeline?.stages
expect(stages?.map((stage) => stage.name)).toEqual(['Source', 'Build', 'Deploy']) 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?.[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 () => { test('uses native execution and state APIs and retains CodeBuild links', async () => {
@@ -49,7 +49,7 @@ export class AwsCodePipelineAdapter implements CloudServiceAdapter {
stages: [ stages: [
stage('Source', action('Source', 'Source', 'S3', {S3Bucket: required(input.values.sourceBucket, 'sourceBucket'), S3ObjectKey: required(input.values.sourceKey, 'sourceKey'), PollForSourceChanges: 'false'}, [], ['SourceOutput'])), 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('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})) 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 [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 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 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)) 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, codeDeployActions))}))} 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()}} 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 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 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 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 } 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'} }
+3
View File
@@ -22,6 +22,7 @@ import { ECRClient } from "@aws-sdk/client-ecr";
import { CodeBuildClient } from "@aws-sdk/client-codebuild"; import { CodeBuildClient } from "@aws-sdk/client-codebuild";
import { CodePipelineClient } from "@aws-sdk/client-codepipeline"; import { CodePipelineClient } from "@aws-sdk/client-codepipeline";
import { CodeDeployClient } from "@aws-sdk/client-codedeploy"; import { CodeDeployClient } from "@aws-sdk/client-codedeploy";
import { ECSClient } from "@aws-sdk/client-ecs";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -75,6 +76,7 @@ export type AwsClients = {
codebuild: CodeBuildClient; codebuild: CodeBuildClient;
codepipeline: CodePipelineClient; codepipeline: CodePipelineClient;
codedeploy: CodeDeployClient; codedeploy: CodeDeployClient;
ecs: ECSClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -112,6 +114,7 @@ function buildClients(accountId: string): AwsClients {
codebuild: new CodeBuildClient(base), codebuild: new CodeBuildClient(base),
codepipeline: new CodePipelineClient(base), codepipeline: new CodePipelineClient(base),
codedeploy: new CodeDeployClient(base), codedeploy: new CodeDeployClient(base),
ecs: new ECSClient(base),
}; };
} }
+1
View File
@@ -6,6 +6,7 @@ export interface CodeBuildEnvironmentVariableOverride {
export interface StartCodeBuildInput { export interface StartCodeBuildInput {
environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[] environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]
buildspecOverride?: string
} }
export interface CodeBuildPhase { export interface CodeBuildPhase {
+45 -2
View File
@@ -19,14 +19,57 @@ export function awsCodeBuildSchema(): ServiceSchema {
{name: 'name', label: 'Project name', type: 'text', required: true, group: 'Required'}, {name: 'name', label: 'Project name', type: 'text', required: true, group: 'Required'},
{name: 'serviceRole', label: 'Service role ARN', 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', 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', name: 'computeType', label: 'Compute type', type: 'select', required: true, group: 'Environment', defaultValue: 'BUILD_GENERAL1_SMALL',
options: [{label: 'General 1 small', value: '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'], actions: ['list', 'create', 'delete', 'inspect'],
capabilities: { capabilities: {
@@ -17,6 +17,8 @@ export interface PipelineActionState {
revisionUrl?: string revisionUrl?: string
codeBuildProject?: string codeBuildProject?: string
codeDeployDeploymentId?: string codeDeployDeploymentId?: string
codeDeployApplication?: string
codeDeployDeploymentGroup?: string
} }
export interface PipelineStageState { export interface PipelineStageState {
@@ -18,8 +18,11 @@ export function awsCodePipelineSchema(): ServiceSchema {
{name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: true, group: 'Source'}, {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: 'sourceKey', label: 'Source object key', type: 'text', required: true, group: 'Source'},
{name: 'codeBuildProject', label: 'CodeBuild project', type: 'text', required: true, group: 'Build'}, {name: 'codeBuildProject', label: 'CodeBuild project', type: 'text', required: true, group: 'Build'},
{name: 'deployBucket', label: 'Deploy S3 bucket', type: 'text', required: true, group: 'Deploy'}, {name: 'codeDeployApplication', label: 'CodeDeploy application', type: 'text', required: true, group: 'ECS deploy'},
{name: 'deployKey', label: 'Deploy object key', type: 'text', required: true, group: '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'], actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [ capabilities: {resourceActions: [
+63
View File
@@ -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', group: 'Compute',
order: 40, order: 40,
}, },
ecs: {
displayName: 'ECS',
iconKey: 'containers',
group: 'Compute',
order: 50,
},
fargate: {
displayName: 'Fargate',
iconKey: 'containers',
group: 'Compute',
order: 51,
},
codebuild: { codebuild: {
displayName: 'CodeBuild', displayName: 'CodeBuild',
iconKey: 'codebuild', iconKey: 'codebuild',
+1
View File
@@ -84,6 +84,7 @@ export interface FieldSchema {
label: string label: string
type: FieldType type: FieldType
required: boolean required: boolean
requiredWhen?: {field: string; equals: string}
description?: string description?: string
group?: string group?: string
span?: boolean span?: boolean
+4
View File
@@ -42,6 +42,8 @@ import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter'
import {AwsCodeBuildAdapter} from './adapter-aws/AwsCodeBuildAdapter' import {AwsCodeBuildAdapter} from './adapter-aws/AwsCodeBuildAdapter'
import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter' import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter'
import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter' import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -86,6 +88,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsCodeBuildAdapter(clients.codebuild), new AwsCodeBuildAdapter(clients.codebuild),
new AwsCodePipelineAdapter(clients.codepipeline), new AwsCodePipelineAdapter(clients.codepipeline),
new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda), new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda),
new AwsEcsAdapter(clients.ecs, clients.elbv2),
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+8 -2
View File
@@ -866,12 +866,18 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
app.post('/:cloud/services/codebuild/resources/:id/builds', async (c) => { 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) if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { return withRuntime(c, async () => {
const body = await jsonBody<{environmentVariablesOverride?: unknown}>(c) const body = await jsonBody<{environmentVariablesOverride?: unknown; buildspecOverride?: unknown}>(c)
const overrides = body.environmentVariablesOverride 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))))) { 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') 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; name: string;
status?: string; status?: string;
lastStatusChange?: 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}); const res = await apiClient.call<CodeBuildBuild, typeof input>(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName});
return res.data; return res.data;
} }
@@ -9,28 +9,34 @@ interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: bool
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) { export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
const client = useQueryClient() const client = useQueryClient()
const projectSourceType = sourceType(resource)
const [startOpen, setStartOpen] = useState(false) const [startOpen, setStartOpen] = useState(false)
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([]) const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
const [buildspecOverride, setBuildspecOverride] = useState(() => defaultBuildspec(projectSourceType))
const [selectedId, setSelectedId] = useState<string>() const [selectedId, setSelectedId] = useState<string>()
const enabled = Boolean(resource && runtimeReachable) const enabled = Boolean(resource && runtimeReachable)
const buildsKey = ['codebuild-builds', resource?.id] 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 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 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}) }}) 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([]) }, [resource?.id]) useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
if (!resource) return null 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) const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
return <section className="table-panel"> 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> <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>} {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> <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}/>} {selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
</section> </section>
} }
function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) { 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">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> 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}) { 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) } 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 active(status?: string): boolean { return status === 'IN_PROGRESS' }
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback } 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 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) const selected = dependencies.data?.lambdaFunctions.find((item) => item.name === functionName)
if (!resource) return null 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"> 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> <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>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}> {!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>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>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> <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> <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>} {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> <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>} {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> <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> </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]}) }}) 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 if (!resource) return null
return <section className="table-panel"> 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>} {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>} {(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> <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]}) { function ActionLinks({action}: {action: PipelineState['stages'][number]['actions'][number]}) {
const links = [ const links = [
action.codeBuildProject ? {href: `/cloud-explorer/aws/codebuild?resource=${encodeURIComponent(action.codeBuildProject)}`, label: 'Open CodeBuild'} : undefined, 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.externalExecutionUrl ? {href: action.externalExecutionUrl, label: 'Execution details'} : undefined,
action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined, action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined,
].filter((item): item is {href: string; label: string} => Boolean(item)) ].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 { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel";
import { EcsServicePanel } from "@/components/EcsServicePanel";
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel"; import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel"; import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
@@ -1012,6 +1013,7 @@ export function DynamicResourceView({
{service === "codebuild" && cloud === "aws" && ( {service === "codebuild" && cloud === "aws" && (
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{(service === "ecs" || service === "fargate") && cloud === "aws" && <EcsServicePanel resource={activeSelected} />}
{service === "codepipeline" && cloud === "aws" && ( {service === "codepipeline" && cloud === "aws" && (
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <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(', ') : '-' }
+18
View File
@@ -40,6 +40,9 @@ importers:
'@aws-sdk/client-ecr': '@aws-sdk/client-ecr':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
'@aws-sdk/client-ecs':
specifier: ^3.1076.0
version: 3.1145.0
'@aws-sdk/client-eks': '@aws-sdk/client-eks':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -293,6 +296,10 @@ packages:
resolution: {integrity: sha512-lZISXKHUDSMMZJonhKeV2h5dWMP2lXwLfhitqdgEE+FziHq4Jx7iW1Vhzx3kJxMMKTisqTADbgO0UCJSd2OxwA==} resolution: {integrity: sha512-lZISXKHUDSMMZJonhKeV2h5dWMP2lXwLfhitqdgEE+FziHq4Jx7iW1Vhzx3kJxMMKTisqTADbgO0UCJSd2OxwA==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/client-eks@3.1076.0':
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==} resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2811,6 +2818,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 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': '@aws-sdk/client-eks@3.1076.0':
dependencies: dependencies:
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0