Files
floci-ui/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts
T
2026-10-02 09:53:45 -04:00

187 lines
8.7 KiB
TypeScript

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<CloudResource[]> {
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<CloudResource | null> {
const [project] = await this.projectsByName([id])
return project ? toResource(project) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
await this.client.send(new DeleteProjectCommand({name: id}))
}
async startBuild(projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild> {
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<CodeBuildBuild[]> {
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<CodeBuildBuild | null> {
const [build] = await this.buildsById([id])
return build ?? null
}
private async projectsByName(names: string[]): Promise<Project[]> {
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<CodeBuildBuild[]> {
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
}