diff --git a/packages/api/package.json b/packages/api/package.json index 65b8951..5127049 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -20,6 +20,7 @@ "@aws-sdk/client-dynamodb": "^3.1088.0", "@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-ecr": "^3.1076.0", + "@aws-sdk/client-ecs": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-eventbridge": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts index 3ebb469..62db00c 100644 --- a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts @@ -21,10 +21,25 @@ describe('AwsCodeBuildAdapter', () => { expect(resources[0].metadata).toMatchObject({sourceType: 'NO_SOURCE', artifactsType: 'NO_ARTIFACTS'}) }) - test('creates only the supported no-source, no-artifacts project contract', async () => { + test('creates a privileged Docker-to-ECR project with no artifacts', async () => { const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {}) - await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole}}) - expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}}) + await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole, buildspec: 'version: 0.2', ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}}) + expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE', buildspec: 'version: 0.2'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL', privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: 'orders'}]}}) + }) + + test('creates S3 source and artifact contracts with standard CodeBuild fields', async () => { + const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {}) + await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'S3', sourceBucket: 'source-bucket', sourceKey: 'releases/orders.zip', buildspec: 'buildspec.local.yml', artifactsType: 'S3', artifactsBucket: 'artifact-bucket', artifactsPrefix: 'builds/orders', ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}}) + expect((sent[0] as CreateProjectCommand).input).toMatchObject({source: {type: 'S3', location: 'source-bucket/releases/orders.zip', buildspec: 'buildspec.local.yml'}, artifacts: {type: 'S3', location: 'artifact-bucket', path: 'builds/orders'}}) + }) + + test('rejects unsupported source types and invalid S3 source configuration', async () => { + const {client} = stub(() => ({})) + const adapter = new AwsCodeBuildAdapter(client) + const docker = {ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'} + await expect(adapter.create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'GITHUB', ...docker}})).rejects.toThrow('sourceType must be S3 or NO_SOURCE') + await expect(adapter.create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'S3', sourceBucket: 'Invalid_Bucket', sourceKey: 'orders.zip', ...docker}})).rejects.toThrow('sourceBucket must be a valid S3 bucket name') + await expect(adapter.create({values: {name: 'orders', serviceRole: project.serviceRole, sourceType: 'S3', sourceBucket: 'source-bucket', ...docker}})).rejects.toThrow('sourceKey is required') }) test('starts builds with AWS environment-variable overrides and exposes log metadata', async () => { diff --git a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts index 7638dfc..e0ed92a 100644 --- a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts +++ b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts @@ -50,12 +50,23 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter { const image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0' const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL') + const sourceType = supportedType(input.values.sourceType, 'sourceType', 'NO_SOURCE') + const artifactsType = supportedType(input.values.artifactsType, 'artifactsType', 'NO_ARTIFACTS') + const ecrRepository = required(input.values.ecrRepository, 'ecrRepository') + if (optional(input.values.dockerPrivilegedMode) !== 'enabled') throw new ValidationError('dockerPrivilegedMode must be enabled') + const buildspec = optional(input.values.buildspec) + const source = sourceType === 'S3' + ? {type: 'S3' as const, location: s3Location(input.values.sourceBucket, input.values.sourceKey, 'source'), buildspec} + : {type: 'NO_SOURCE' as const, buildspec} + const artifacts = artifactsType === 'S3' + ? {type: 'S3' as const, location: s3Bucket(input.values.artifactsBucket, 'artifactsBucket'), path: optional(input.values.artifactsPrefix)} + : {type: 'NO_ARTIFACTS' as const} const response = await this.client.send(new CreateProjectCommand({ name, serviceRole, - source: {type: 'NO_SOURCE'}, - artifacts: {type: 'NO_ARTIFACTS'}, - environment: {type: 'LINUX_CONTAINER', image, computeType}, + source, + artifacts, + environment: {type: 'LINUX_CONTAINER', image, computeType, privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: ecrRepository}]}, })) if (!response.project) throw new RuntimeError(`CodeBuild did not return project ${name}`) return toResource(response.project) @@ -69,6 +80,7 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter { const response = await this.client.send(new StartBuildCommand({ projectName, environmentVariablesOverride: input.environmentVariablesOverride, + buildspecOverride: input.buildspecOverride, })) if (!response.build) throw new RuntimeError(`CodeBuild did not return a build for ${projectName}`) return toBuild(response.build) @@ -122,6 +134,8 @@ function toResource(project: Project): CloudResource { environmentType: project.environment?.type, environmentImage: project.environment?.image, computeType: project.environment?.computeType, + dockerPrivilegedMode: project.environment?.privilegedMode ?? false, + ecrRepository: project.environment?.environmentVariables?.find((variable) => variable.name === 'ECR_REPOSITORY')?.value, }, } } @@ -147,6 +161,26 @@ function optional(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } +function supportedType(value: unknown, field: string, fallback: 'S3' | 'NO_SOURCE' | 'NO_ARTIFACTS'): 'S3' | 'NO_SOURCE' | 'NO_ARTIFACTS' { + const type = optional(value) ?? fallback + if (type !== 'S3' && type !== 'NO_SOURCE' && type !== 'NO_ARTIFACTS') { + throw new ValidationError(field === 'sourceType' ? 'sourceType must be S3 or NO_SOURCE' : 'artifactsType must be S3 or NO_ARTIFACTS') + } + if (field === 'sourceType' && type === 'NO_ARTIFACTS') throw new ValidationError('sourceType must be S3 or NO_SOURCE') + if (field === 'artifactsType' && type === 'NO_SOURCE') throw new ValidationError('artifactsType must be S3 or NO_ARTIFACTS') + return type +} + +function s3Location(bucket: unknown, key: unknown, field: string): string { + return `${s3Bucket(bucket, `${field}Bucket`)}/${required(key, `${field}Key`)}` +} + +function s3Bucket(value: unknown, field: string): string { + const bucket = required(value, field) + if (!/^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$/.test(bucket)) throw new ValidationError(`${field} must be a valid S3 bucket name`) + return bucket +} + function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null } diff --git a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts index 2740695..b47c98a 100644 --- a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts +++ b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts @@ -117,7 +117,7 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter { const group = response.deploymentGroupInfo if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`) const configuration = group.deploymentConfigName - return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}} + return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}} } } diff --git a/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts index 29dfd09..565c39d 100644 --- a/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts @@ -9,15 +9,15 @@ function stub(handler: (command: object) => Record) { } describe('AwsCodePipelineAdapter', () => { - test('creates the documented S3, CodeBuild, S3 local pipeline definition', async () => { + test('creates the documented S3, CodeBuild, CodeDeployToECS pipeline definition', async () => { const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: []} const {client, sent} = stub((command) => command instanceof CreatePipelineCommand ? {pipeline} : {}) - await new AwsCodePipelineAdapter(client).create({values: {name: 'release', roleArn: pipeline.roleArn, artifactBucket: 'artifacts', sourceBucket: 'source', sourceKey: 'app.zip', codeBuildProject: 'build-app', deployBucket: 'deploy', deployKey: 'releases/app.zip'}}) + await new AwsCodePipelineAdapter(client).create({values: {name: 'release', roleArn: pipeline.roleArn, artifactBucket: 'artifacts', sourceBucket: 'source', sourceKey: 'app.zip', codeBuildProject: 'build-app', codeDeployApplication: 'orders', codeDeployDeploymentGroup: 'orders-bluegreen', taskDefinitionPath: 'taskdef.json', appSpecPath: 'appspec.yaml', imageContainerName: 'IMAGE1_NAME'}}) const stages = (sent[0] as CreatePipelineCommand).input.pipeline?.stages expect(stages?.map((stage) => stage.name)).toEqual(['Source', 'Build', 'Deploy']) - expect(stages?.map((stage) => stage.actions?.[0].actionTypeId?.provider)).toEqual(['S3', 'CodeBuild', 'S3']) + expect(stages?.map((stage) => stage.actions?.[0].actionTypeId?.provider)).toEqual(['S3', 'CodeBuild', 'CodeDeployToECS']) expect(stages?.[1].actions?.[0].configuration).toEqual({ProjectName: 'build-app'}) - expect(stages?.[2].actions?.[0].configuration).toEqual({BucketName: 'deploy', ObjectKey: 'releases/app.zip', Extract: 'true'}) + expect(stages?.[2].actions?.[0].configuration).toEqual({ApplicationName: 'orders', DeploymentGroupName: 'orders-bluegreen', TaskDefinitionTemplateArtifact: 'BuildOutput', TaskDefinitionTemplatePath: 'taskdef.json', AppSpecTemplateArtifact: 'BuildOutput', AppSpecTemplatePath: 'appspec.yaml', Image1ArtifactName: 'BuildOutput', Image1ContainerName: 'IMAGE1_NAME'}) }) test('uses native execution and state APIs and retains CodeBuild links', async () => { diff --git a/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts index ea2fd66..f1c86be 100644 --- a/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts +++ b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts @@ -49,7 +49,7 @@ export class AwsCodePipelineAdapter implements CloudServiceAdapter { stages: [ stage('Source', action('Source', 'Source', 'S3', {S3Bucket: required(input.values.sourceBucket, 'sourceBucket'), S3ObjectKey: required(input.values.sourceKey, 'sourceKey'), PollForSourceChanges: 'false'}, [], ['SourceOutput'])), stage('Build', action('Build', 'Build', 'CodeBuild', {ProjectName: required(input.values.codeBuildProject, 'codeBuildProject')}, ['SourceOutput'], ['BuildOutput'])), - stage('Deploy', action('Deploy', 'Deploy', 'S3', {BucketName: required(input.values.deployBucket, 'deployBucket'), ObjectKey: required(input.values.deployKey, 'deployKey'), Extract: 'true'}, ['BuildOutput'], [])), + stage('Deploy', ecsDeployAction(input.values)), ], } const response = await this.client.send(new CreatePipelineCommand({pipeline: declaration})) @@ -82,8 +82,8 @@ export class AwsCodePipelineAdapter implements CloudServiceAdapter { const [pipeline, state] = await Promise.all([this.client.send(new GetPipelineCommand({name: id})), this.client.send(new GetPipelineStateCommand({name: id}))]) const actions = (pipeline.pipeline?.stages ?? []).flatMap((stage) => stage.actions ?? []) const codeBuildProjects = new Map(actions.filter((item) => item.actionTypeId?.provider === 'CodeBuild').map((item) => [item.name, item.configuration?.ProjectName] as const)) - const codeDeployActions = new Set(actions.filter((item) => item.actionTypeId?.provider === 'CodeDeploy').map((item) => item.name)) - return {stages: (state.stageStates ?? []).map((stage) => ({name: stage.stageName ?? 'Unknown', status: stage.latestExecution?.status, actions: (stage.actionStates ?? []).map((actionState) => toActionState(actionState, codeBuildProjects, codeDeployActions))}))} + const ecsDeployActions = new Map(actions.filter((item) => item.actionTypeId?.provider === 'CodeDeployToECS').map((item) => [item.name, {application: item.configuration?.ApplicationName, deploymentGroup: item.configuration?.DeploymentGroupName}] as const)) + return {stages: (state.stageStates ?? []).map((stage) => ({name: stage.stageName ?? 'Unknown', status: stage.latestExecution?.status, actions: (stage.actionStates ?? []).map((actionState) => toActionState(actionState, codeBuildProjects, ecsDeployActions))}))} } } @@ -97,12 +97,14 @@ function toSummaryResource(pipeline: {name?: string; version?: number; created?: return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: null, createdAt: pipeline.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, updatedAt: pipeline.updated?.toISOString()}} } -function toActionState(action: ActionState, codeBuildProjects: Map, codeDeployActions: Set) { +function toActionState(action: ActionState, codeBuildProjects: Map, ecsDeployActions: Map) { 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) { return {name, actions: [item]} } +function stage(name: string, item: {name: string; actionTypeId: {category: 'Source' | 'Build' | 'Deploy'; owner: 'AWS'; provider: string; version: string}; configuration: Record; 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, 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) { return {name: 'Deploy', actionTypeId: {category: 'Deploy' as const, owner: 'AWS' as const, provider: 'CodeDeployToECS', version: '1'}, configuration: {ApplicationName: required(values.codeDeployApplication, 'codeDeployApplication'), DeploymentGroupName: required(values.codeDeployDeploymentGroup, 'codeDeployDeploymentGroup'), TaskDefinitionTemplateArtifact: 'BuildOutput', TaskDefinitionTemplatePath: required(values.taskDefinitionPath, 'taskDefinitionPath'), AppSpecTemplateArtifact: 'BuildOutput', AppSpecTemplatePath: required(values.appSpecPath, 'appSpecPath'), Image1ArtifactName: 'BuildOutput', Image1ContainerName: required(values.imageContainerName, 'imageContainerName')}, inputArtifacts: [{name: 'BuildOutput'}], outputArtifacts: []} } function required(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`); return value.trim() } function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null } diff --git a/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts b/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts new file mode 100644 index 0000000..17997fb --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts @@ -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}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsEcsAdapter.ts b/packages/api/src/adapter-aws/AwsEcsAdapter.ts new file mode 100644 index 0000000..5bc9a79 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEcsAdapter.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>> { + 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>> { + 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 } diff --git a/packages/api/src/adapter-aws/AwsFargateAdapter.ts b/packages/api/src/adapter-aws/AwsFargateAdapter.ts new file mode 100644 index 0000000..798736b --- /dev/null +++ b/packages/api/src/adapter-aws/AwsFargateAdapter.ts @@ -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 { + const resources = await this.ecs.list(query) + return resources.filter(isFargate).map(toFargateResource) + } + + async get(id: string): Promise { + const resource = await this.ecs.get(id) + return resource && isFargate(resource) ? toFargateResource(resource) : null + } + + async create(input: CreateResourceInput): Promise { + return toFargateResource(await this.ecs.create({ + ...input, + values: {...input.values, launchType: 'FARGATE'}, + })) + } + + async delete(id: string): Promise { + 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'} } diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 159b015..67fdc18 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -22,6 +22,7 @@ import { ECRClient } from "@aws-sdk/client-ecr"; import { CodeBuildClient } from "@aws-sdk/client-codebuild"; import { CodePipelineClient } from "@aws-sdk/client-codepipeline"; import { CodeDeployClient } from "@aws-sdk/client-codedeploy"; +import { ECSClient } from "@aws-sdk/client-ecs"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -75,6 +76,7 @@ export type AwsClients = { codebuild: CodeBuildClient; codepipeline: CodePipelineClient; codedeploy: CodeDeployClient; + ecs: ECSClient; }; export type AwsClientName = keyof AwsClients; @@ -112,6 +114,7 @@ function buildClients(accountId: string): AwsClients { codebuild: new CodeBuildClient(base), codepipeline: new CodePipelineClient(base), codedeploy: new CodeDeployClient(base), + ecs: new ECSClient(base), }; } diff --git a/packages/api/src/cloud-spi/codebuild.ts b/packages/api/src/cloud-spi/codebuild.ts index 93277bf..cac2af1 100644 --- a/packages/api/src/cloud-spi/codebuild.ts +++ b/packages/api/src/cloud-spi/codebuild.ts @@ -6,6 +6,7 @@ export interface CodeBuildEnvironmentVariableOverride { export interface StartCodeBuildInput { environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[] + buildspecOverride?: string } export interface CodeBuildPhase { diff --git a/packages/api/src/cloud-spi/codebuildSchema.ts b/packages/api/src/cloud-spi/codebuildSchema.ts index 1b8d7a7..910893c 100644 --- a/packages/api/src/cloud-spi/codebuildSchema.ts +++ b/packages/api/src/cloud-spi/codebuildSchema.ts @@ -19,14 +19,57 @@ export function awsCodeBuildSchema(): ServiceSchema { {name: 'name', label: 'Project name', type: 'text', required: true, group: 'Required'}, {name: 'serviceRole', label: 'Service role ARN', type: 'text', required: true, group: 'Required'}, { - name: 'environmentImage', label: 'Environment image', type: 'text', required: false, group: 'Environment', + name: 'sourceType', label: 'Source type', type: 'select', required: true, group: 'Source', defaultValue: 'NO_SOURCE', + options: [{label: 'No source', value: 'NO_SOURCE'}, {label: 'Amazon S3', value: 'S3'}], + description: 'S3 source is supported locally. GitHub and Gitea sources are not executed by the local runner.', + }, + { + name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: false, requiredWhen: {field: 'sourceType', equals: 'S3'}, group: 'Source', + validation: {pattern: '^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$', message: 'Use a valid 3-63 character S3 bucket name.'}, + }, + { + name: 'sourceKey', label: 'Source object key', type: 'text', required: false, requiredWhen: {field: 'sourceType', equals: 'S3'}, group: 'Source', + description: 'The ZIP source object key in the source bucket.', + }, + { + name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true, + defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: 'version: 0.2\nphases:\n pre_build:\n commands:\n - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)\n - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY\n - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\n build:\n commands:\n - IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}\n - docker build -t $REPOSITORY_URI:$IMAGE_TAG .\n - docker push $REPOSITORY_URI:$IMAGE_TAG\n post_build:\n commands:\n - printf \'[{"name":"IMAGE1_NAME","imageUri":"%s"}]\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml', S3: ''}}, + description: 'For no source, this is a Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override.', + }, + { + name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS', + options: [{label: 'No artifacts', value: 'NO_ARTIFACTS'}, {label: 'Amazon S3', value: 'S3'}], + }, + { + name: 'artifactsBucket', label: 'Artifact S3 bucket', type: 'text', required: false, requiredWhen: {field: 'artifactsType', equals: 'S3'}, group: 'Artifacts', + validation: {pattern: '^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$', message: 'Use a valid 3-63 character S3 bucket name.'}, + }, + { + name: 'artifactsPrefix', label: 'Artifact prefix', type: 'text', required: false, group: 'Artifacts', + description: 'Optional S3 key prefix for build artifacts.', + }, + { + name: 'environmentImage', label: 'Runtime image', type: 'select', required: true, group: 'Environment', defaultValue: 'aws/codebuild/standard:7.0', - description: 'AWS CodeBuild managed image identifier.', + options: [ + {label: 'CodeBuild Standard 7.0', value: 'aws/codebuild/standard:7.0'}, + {label: 'Node.js 20 (Debian)', value: 'node:20-bookworm'}, + ], + description: 'Choose the container runtime for your build commands.', }, { name: 'computeType', label: 'Compute type', type: 'select', required: true, group: 'Environment', defaultValue: 'BUILD_GENERAL1_SMALL', options: [{label: 'General 1 small', value: 'BUILD_GENERAL1_SMALL'}], }, + { + name: 'ecrRepository', label: 'ECR repository', type: 'text', required: true, group: 'Docker and ECR', + description: 'An existing ECR repository. The project exports it as ECR_REPOSITORY for the Docker buildspec.', + }, + { + name: 'dockerPrivilegedMode', label: 'Docker daemon', type: 'select', required: true, group: 'Docker and ECR', defaultValue: 'enabled', + options: [{label: 'Enabled (required for Docker builds)', value: 'enabled'}], + description: 'CodeBuild runs Docker-in-Docker in privileged mode. This console flow targets ECS EC2 only.', + }, ], actions: ['list', 'create', 'delete', 'inspect'], capabilities: { diff --git a/packages/api/src/cloud-spi/codepipeline.ts b/packages/api/src/cloud-spi/codepipeline.ts index ef4cf6d..64c0f91 100644 --- a/packages/api/src/cloud-spi/codepipeline.ts +++ b/packages/api/src/cloud-spi/codepipeline.ts @@ -17,6 +17,8 @@ export interface PipelineActionState { revisionUrl?: string codeBuildProject?: string codeDeployDeploymentId?: string + codeDeployApplication?: string + codeDeployDeploymentGroup?: string } export interface PipelineStageState { diff --git a/packages/api/src/cloud-spi/codepipelineSchema.ts b/packages/api/src/cloud-spi/codepipelineSchema.ts index 63be1aa..e640ce3 100644 --- a/packages/api/src/cloud-spi/codepipelineSchema.ts +++ b/packages/api/src/cloud-spi/codepipelineSchema.ts @@ -18,8 +18,11 @@ export function awsCodePipelineSchema(): ServiceSchema { {name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: true, group: 'Source'}, {name: 'sourceKey', label: 'Source object key', type: 'text', required: true, group: 'Source'}, {name: 'codeBuildProject', label: 'CodeBuild project', type: 'text', required: true, group: 'Build'}, - {name: 'deployBucket', label: 'Deploy S3 bucket', type: 'text', required: true, group: 'Deploy'}, - {name: 'deployKey', label: 'Deploy object key', type: 'text', required: true, group: 'Deploy'}, + {name: 'codeDeployApplication', label: 'CodeDeploy application', type: 'text', required: true, group: 'ECS deploy'}, + {name: 'codeDeployDeploymentGroup', label: 'CodeDeploy deployment group', type: 'text', required: true, group: 'ECS deploy'}, + {name: 'taskDefinitionPath', label: 'Task definition path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'taskdef.json'}, + {name: 'appSpecPath', label: 'AppSpec path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'appspec.yaml'}, + {name: 'imageContainerName', label: 'Task definition image placeholder', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'IMAGE1_NAME', description: 'The placeholder in taskdef.json, for example . CodeBuild supplies the ECR image from BuildOutput. The CodeDeploy deployment group must target an ECS EC2 service.'}, ], actions: ['list', 'create', 'delete', 'inspect'], capabilities: {resourceActions: [ diff --git a/packages/api/src/cloud-spi/ecsSchema.ts b/packages/api/src/cloud-spi/ecsSchema.ts new file mode 100644 index 0000000..ed56aae --- /dev/null +++ b/packages/api/src/cloud-spi/ecsSchema.ts @@ -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), + }, + } +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index 2ba4157..a69daa6 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -79,6 +79,18 @@ export const SERVICE_CATALOG = { group: 'Compute', order: 40, }, + ecs: { + displayName: 'ECS', + iconKey: 'containers', + group: 'Compute', + order: 50, + }, + fargate: { + displayName: 'Fargate', + iconKey: 'containers', + group: 'Compute', + order: 51, + }, codebuild: { displayName: 'CodeBuild', iconKey: 'codebuild', diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 1f59d10..cf4a250 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -84,6 +84,7 @@ export interface FieldSchema { label: string type: FieldType required: boolean + requiredWhen?: {field: string; equals: string} description?: string group?: string span?: boolean diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index e21d720..f09db33 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -42,6 +42,8 @@ import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter' import {AwsCodeBuildAdapter} from './adapter-aws/AwsCodeBuildAdapter' import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter' import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter' +import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter' +import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -86,6 +88,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsCodeBuildAdapter(clients.codebuild), new AwsCodePipelineAdapter(clients.codepipeline), new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda), + new AwsEcsAdapter(clients.ecs, clients.elbv2), + new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index bf925d9..02973ce 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -866,12 +866,18 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { app.post('/:cloud/services/codebuild/resources/:id/builds', async (c) => { if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) return withRuntime(c, async () => { - const body = await jsonBody<{environmentVariablesOverride?: unknown}>(c) + const body = await jsonBody<{environmentVariablesOverride?: unknown; buildspecOverride?: unknown}>(c) const overrides = body.environmentVariablesOverride if (overrides !== undefined && (!Array.isArray(overrides) || overrides.some((item) => !item || typeof item !== 'object' || typeof (item as {name?: unknown}).name !== 'string' || typeof (item as {value?: unknown}).value !== 'string' || ((item as {type?: unknown}).type !== undefined && !['PLAINTEXT', 'PARAMETER_STORE', 'SECRETS_MANAGER'].includes((item as {type: string}).type))))) { throw new ValidationError('environmentVariablesOverride must contain name, value, and an optional supported type') } - return c.json(await svc(c).startCodeBuild('aws', c.req.param('id'), {environmentVariablesOverride: overrides as never}), 201) + if (body.buildspecOverride !== undefined && typeof body.buildspecOverride !== 'string') { + throw new ValidationError('buildspecOverride must be a string') + } + const buildspecOverride = typeof body.buildspecOverride === 'string' && body.buildspecOverride.trim() + ? body.buildspecOverride + : undefined + return c.json(await svc(c).startCodeBuild('aws', c.req.param('id'), {environmentVariablesOverride: overrides as never, buildspecOverride}), 201) }) }) diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 5e81e9e..93af312 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -326,7 +326,7 @@ export interface PipelineState { name: string; status?: string; lastStatusChange?: string; - actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string}>; + actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string; codeDeployApplication?: string; codeDeployDeploymentGroup?: string}>; }>; } @@ -960,7 +960,7 @@ export async function stopWorkflowExecution(stateMachineArn: string, executionAr ); } -export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]}): Promise { +export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]; buildspecOverride?: string}): Promise { const res = await apiClient.call(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName}); return res.data; } diff --git a/packages/frontend/src/components/CodeBuildPanel.tsx b/packages/frontend/src/components/CodeBuildPanel.tsx index 43c1300..d549541 100644 --- a/packages/frontend/src/components/CodeBuildPanel.tsx +++ b/packages/frontend/src/components/CodeBuildPanel.tsx @@ -9,28 +9,34 @@ interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: bool export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) { const client = useQueryClient() + const projectSourceType = sourceType(resource) const [startOpen, setStartOpen] = useState(false) const [overrides, setOverrides] = useState([]) + const [buildspecOverride, setBuildspecOverride] = useState(() => defaultBuildspec(projectSourceType)) const [selectedId, setSelectedId] = useState() const enabled = Boolean(resource && runtimeReachable) const buildsKey = ['codebuild-builds', resource?.id] const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false}) const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false}) - const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); void client.invalidateQueries({queryKey: buildsKey}) }}) - useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]) }, [resource?.id]) + const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(DEFAULT_BUILDSPEC); void client.invalidateQueries({queryKey: buildsKey}) }}) + useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType]) if (!resource) return null + const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId) return

Builds

Run and inspect builds

- {startOpen && setStartOpen(false)} onStart={() => start.mutate()}/>} + {startOpen && ( + setStartOpen(false)} onStart={() => start.mutate()}/> + )} + {ecrRepository &&

Docker builds push to ECR repository {ecrRepository} and publish `imageDetail.json`, `taskdef.json`, and `appspec.yaml` for CodeDeployToECS.

} {builds.isError &&

{message(builds.error, 'Unable to load builds.')}

}

Build history

{builds.data?.length ?? 0}
{(builds.data ?? []).map((build) => setSelectedId(build.id)} style={{cursor: 'pointer'}}>)}
BuildStatusPhaseStarted
#{build.buildNumber ?? build.id}{build.buildStatus ?? '-'}{build.currentPhase ?? '-'}{formatDateTime(build.startTime) ?? '-'}
{!builds.isLoading && builds.data?.length === 0 &&

No builds yet.

}
{selectedId && }
} -function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) { - return
Start build

Environment variable overrides apply only to this build.

{overrides.map((override, index) =>
onChange(replace(overrides, index, {...override, name: event.target.value}))}/> onChange(replace(overrides, index, {...override, value: event.target.value}))}/>
)}{Boolean(error) &&

{message(error, 'Unable to start build.')}

}
+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
Start build

{requiresBuildspec ? 'A buildspec override lets this no-source project run a real build.' : 'Leave the buildspec override blank to use the S3 source buildspec.'}