import { BatchGetBuildsCommand, BatchGetProjectsCommand, CodeBuildClient, ComputeType, CreateProjectCommand, DeleteProjectCommand, ListBuildsForProjectCommand, ListProjectsCommand, StartBuildCommand, type Build, type Project, } from '@aws-sdk/client-codebuild' import {RuntimeError, ValidationError} from '../cloud-spi/errors' import {awsCodeBuildSchema} from '../cloud-spi/codebuildSchema' import type {CodeBuildBuild, StartCodeBuildInput} from '../cloud-spi/codebuild' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' export class AwsCodeBuildAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'codebuild' as const constructor(private readonly client: CodeBuildClient) {} schema(): ServiceSchema { return awsCodeBuildSchema() } async list(query: ResourceQuery = {}): Promise { const names: string[] = [] let nextToken: string | undefined do { const response = await this.client.send(new ListProjectsCommand(nextToken ? {nextToken} : {})) names.push(...(response.projects ?? [])) nextToken = response.nextToken } while (nextToken) const projects = await this.projectsByName(names) const normalized = query.search?.trim().toLowerCase() return projects.map(toResource).filter((project) => !normalized || project.name.toLowerCase().includes(normalized)) } async get(id: string): Promise { const [project] = await this.projectsByName([id]) return project ? toResource(project) : null } async create(input: CreateResourceInput): Promise { const name = required(input.values.name, 'name') const serviceRole = required(input.values.serviceRole, 'serviceRole') 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, 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) } async delete(id: string): Promise { await this.client.send(new DeleteProjectCommand({name: id})) } async startBuild(projectName: string, input: StartCodeBuildInput): Promise { 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) } async listBuilds(projectName: string): Promise { const ids: string[] = [] let nextToken: string | undefined do { const response = await this.client.send(new ListBuildsForProjectCommand(nextToken ? {projectName, nextToken} : {projectName})) ids.push(...(response.ids ?? [])) nextToken = response.nextToken } while (nextToken) return this.buildsById(ids) } async getBuild(id: string): Promise { const [build] = await this.buildsById([id]) return build ?? null } private async projectsByName(names: string[]): Promise { const projects: Project[] = [] for (let index = 0; index < names.length; index += 100) { const response = await this.client.send(new BatchGetProjectsCommand({names: names.slice(index, index + 100)})) projects.push(...(response.projects ?? [])) } return projects } private async buildsById(ids: string[]): Promise { const builds: CodeBuildBuild[] = [] for (let index = 0; index < ids.length; index += 100) { const response = await this.client.send(new BatchGetBuildsCommand({ids: ids.slice(index, index + 100)})) builds.push(...(response.builds ?? []).map(toBuild)) } return builds } } function toResource(project: Project): CloudResource { const name = project.name ?? '' return { id: name, name, cloud: 'aws', service: 'codebuild', type: 'codebuild-project', region: regionFromArn(project.arn), createdAt: project.created ? project.created.toISOString() : null, metadata: { arn: project.arn, serviceRole: project.serviceRole, sourceType: project.source?.type, artifactsType: project.artifacts?.type, 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, }, } } function toBuild(build: Build): CodeBuildBuild { const id = build.id ?? build.arn if (!id || !build.projectName) throw new RuntimeError('CodeBuild returned a build without an id or project name') return { id, arn: build.arn, projectName: build.projectName, buildNumber: build.buildNumber, buildStatus: build.buildStatus, currentPhase: build.currentPhase, startTime: build.startTime, endTime: build.endTime, phases: (build.phases ?? []).map((phase) => ({phaseType: phase.phaseType ?? 'UNKNOWN', phaseStatus: phase.phaseStatus, startTime: phase.startTime, endTime: phase.endTime, contexts: phase.contexts ?? []})), logGroupName: build.logs?.groupName, logStreamName: build.logs?.streamName, } } function required(value: unknown, field: string): string { const result = optional(value) if (!result) throw new ValidationError(`${field} is required`) return result } 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 }