CI/CD - CodeBuild + CodeDeploy + CodePipeline

This commit is contained in:
Zakaria
2026-10-01 09:18:04 -04:00
parent aa02cdb7f6
commit 7f0d707578
41 changed files with 1817 additions and 78 deletions
@@ -0,0 +1,50 @@
import {describe, expect, test} from 'bun:test'
import {BatchGetBuildsCommand, BatchGetProjectsCommand, CreateProjectCommand, DeleteProjectCommand, ListBuildsForProjectCommand, ListProjectsCommand, StartBuildCommand, type CodeBuildClient} from '@aws-sdk/client-codebuild'
import {AwsCodeBuildAdapter} from './AwsCodeBuildAdapter'
function stub(handler: (command: object) => Record<string, unknown>) {
const sent: object[] = []
const client = {async send(command: object) { sent.push(command); return handler(command) }} as unknown as CodeBuildClient
return {client, sent}
}
const project = {name: 'orders', arn: 'arn:aws:codebuild:us-east-1:000000000000:project/orders', created: new Date('2026-10-01T10:00:00.000Z'), serviceRole: 'arn:aws:iam::000000000000:role/codebuild', source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}}
const build = {id: 'orders:build-id', arn: 'arn:aws:codebuild:us-east-1:000000000000:build/orders:build-id', projectName: 'orders', buildNumber: 3, buildStatus: 'SUCCEEDED', phases: [{phaseType: 'BUILD', phaseStatus: 'SUCCEEDED', contexts: []}], logs: {groupName: '/aws/codebuild/orders', streamName: 'stream'}}
describe('AwsCodeBuildAdapter', () => {
test('uses list and batch-get project contracts for the project table', async () => {
const {client, sent} = stub((command) => command instanceof ListProjectsCommand ? {projects: ['orders']} : command instanceof BatchGetProjectsCommand ? {projects: [project]} : {})
const resources = await new AwsCodeBuildAdapter(client).list()
expect(sent[0]).toBeInstanceOf(ListProjectsCommand)
expect((sent[1] as BatchGetProjectsCommand).input.names).toEqual(['orders'])
expect(resources[0]).toMatchObject({id: 'orders', name: 'orders', service: 'codebuild', region: 'us-east-1'})
expect(resources[0].metadata).toMatchObject({sourceType: 'NO_SOURCE', artifactsType: 'NO_ARTIFACTS'})
})
test('creates only the supported no-source, no-artifacts project contract', 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'}})
})
test('starts builds with AWS environment-variable overrides and exposes log metadata', async () => {
const {client, sent} = stub((command) => command instanceof StartBuildCommand ? {build} : {})
const result = await new AwsCodeBuildAdapter(client).startBuild('orders', {environmentVariablesOverride: [{name: 'MODE', value: 'test'}]})
expect((sent[0] as StartBuildCommand).input).toEqual({projectName: 'orders', environmentVariablesOverride: [{name: 'MODE', value: 'test'}]})
expect(result).toMatchObject({id: 'orders:build-id', logGroupName: '/aws/codebuild/orders', logStreamName: 'stream'})
})
test('lists build ids then batch-gets detailed phases', async () => {
const {client, sent} = stub((command) => command instanceof ListBuildsForProjectCommand ? {ids: [build.id]} : command instanceof BatchGetBuildsCommand ? {builds: [build]} : {})
const builds = await new AwsCodeBuildAdapter(client).listBuilds('orders')
expect((sent[0] as ListBuildsForProjectCommand).input.projectName).toBe('orders')
expect((sent[1] as BatchGetBuildsCommand).input.ids).toEqual([build.id])
expect(builds[0].phases[0]).toMatchObject({phaseType: 'BUILD', phaseStatus: 'SUCCEEDED'})
})
test('deletes projects by their CodeBuild name', async () => {
const {client, sent} = stub(() => ({}))
await new AwsCodeBuildAdapter(client).delete('orders')
expect((sent[0] as DeleteProjectCommand).input.name).toBe('orders')
})
})
@@ -0,0 +1,152 @@
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 response = await this.client.send(new CreateProjectCommand({
name,
serviceRole,
source: {type: 'NO_SOURCE'},
artifacts: {type: 'NO_ARTIFACTS'},
environment: {type: 'LINUX_CONTAINER', image, computeType},
}))
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,
}))
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,
},
}
}
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 regionFromArn(arn?: string): string | null {
return arn?.split(':')[3] || null
}
@@ -0,0 +1,30 @@
import {describe, expect, test} from 'bun:test'
import {CreateDeploymentCommand, GetDeploymentCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy'
import type {LambdaClient} from '@aws-sdk/client-lambda'
import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter'
describe('AwsCodeDeployAdapter', () => {
test('creates a native Lambda AppSpec revision with traffic hooks', async () => {
const sent: object[] = []
const client = {async send(command: object) {
sent.push(command)
if (command instanceof CreateDeploymentCommand) return {deploymentId: 'd-1'}
if (command instanceof GetDeploymentCommand) return {deploymentInfo: {deploymentId: 'd-1', applicationName: 'orders', deploymentGroupName: 'production', status: 'Created'}}
return {}
}} as unknown as CodeDeployClient
const lambda = {async send() { return {FunctionVersion: '5'} }} as unknown as LambdaClient
const adapter = new AwsCodeDeployAdapter(client, lambda)
await adapter.startCodeDeployDeployment('orders', 'production', {functionName: 'orders-api', alias: 'live', currentVersion: '4', targetVersion: '5', beforeAllowTrafficHook: 'verify-before', afterAllowTrafficHook: 'verify-after'})
const command = sent.find((item) => item instanceof CreateDeploymentCommand) as CreateDeploymentCommand
const appSpec = JSON.parse(command.input.revision?.appSpecContent?.content ?? '{}')
expect(appSpec.Resources[0]['orders-api'].Properties).toEqual({Name: 'orders-api', Alias: 'live', CurrentVersion: '4', TargetVersion: '5'})
expect(appSpec.Hooks).toEqual([{BeforeAllowTraffic: 'verify-before'}, {AfterAllowTraffic: 'verify-after'}])
})
test('stops a deployment with the requested rollback behavior', async () => {
const sent: object[] = []
const client = {async send(command: object) { sent.push(command); return {} }} as unknown as CodeDeployClient
await new AwsCodeDeployAdapter(client, {} as LambdaClient).stopCodeDeployDeployment('d-1', true)
expect((sent[0] as StopDeploymentCommand).input).toEqual({deploymentId: 'd-1', autoRollbackEnabled: true})
})
})
@@ -0,0 +1,131 @@
import {
CreateApplicationCommand,
CreateDeploymentCommand,
CreateDeploymentGroupCommand,
DeleteApplicationCommand,
DeleteDeploymentGroupCommand,
GetDeploymentCommand,
GetDeploymentGroupCommand,
ListApplicationsCommand,
ListDeploymentGroupsCommand,
ListDeploymentsCommand,
StopDeploymentCommand,
type CodeDeployClient,
} from '@aws-sdk/client-codedeploy'
import {GetAliasCommand, ListAliasesCommand, ListFunctionsCommand, ListVersionsByFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsCodeDeploySchema} from '../cloud-spi/codedeploySchema'
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from '../cloud-spi/codedeploy'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsCodeDeployAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'codedeploy' as const
constructor(private readonly client: CodeDeployClient, private readonly lambda: LambdaClient) {}
schema(): ServiceSchema { return awsCodeDeploySchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources: CloudResource[] = []
const applications = await this.client.send(new ListApplicationsCommand({}))
for (const applicationName of applications.applications ?? []) {
const groups = await this.client.send(new ListDeploymentGroupsCommand({applicationName}))
for (const deploymentGroupName of groups.deploymentGroups ?? []) {
resources.push(await this.groupResource(applicationName, deploymentGroupName))
}
}
const search = query.search?.trim().toLowerCase()
return resources.filter((resource) => !search || resource.name.toLowerCase().includes(search) || String(resource.metadata.applicationName).toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
const [applicationName, deploymentGroupName] = splitId(id)
return this.groupResource(applicationName, deploymentGroupName)
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const applicationName = required(input.values.applicationName, 'applicationName')
const deploymentGroupName = required(input.values.deploymentGroupName, 'deploymentGroupName')
const applications = await this.client.send(new ListApplicationsCommand({}))
if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform: 'Lambda'}))
await this.client.send(new CreateDeploymentGroupCommand({
applicationName,
deploymentGroupName,
serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'),
deploymentConfigName: deploymentConfig(input.values.deploymentConfig),
deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']},
}))
return this.groupResource(applicationName, deploymentGroupName)
}
async delete(id: string): Promise<void> {
const [applicationName, deploymentGroupName] = splitId(id)
await this.client.send(new DeleteDeploymentGroupCommand({applicationName, deploymentGroupName}))
const groups = await this.client.send(new ListDeploymentGroupsCommand({applicationName}))
if ((groups.deploymentGroups ?? []).length === 0) await this.client.send(new DeleteApplicationCommand({applicationName}))
}
async dependencies(): Promise<Record<string, unknown>> {
const functions = await this.lambda.send(new ListFunctionsCommand({}))
const lambdaFunctions = await Promise.all((functions.Functions ?? []).flatMap(async (functionConfiguration) => {
const name = functionConfiguration.FunctionName
if (!name) return []
const [aliases, versions] = await Promise.all([
this.lambda.send(new ListAliasesCommand({FunctionName: name})),
this.lambda.send(new ListVersionsByFunctionCommand({FunctionName: name})),
])
return [{name, aliases: (aliases.Aliases ?? []).map((alias) => alias.Name).filter((alias): alias is string => Boolean(alias)), versions: (versions.Versions ?? []).map((version) => version.Version).filter((version): version is string => Boolean(version) && version !== '$LATEST')}]
}))
return {lambdaFunctions}
}
async startCodeDeployDeployment(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> {
const hooks = hooksFor(input)
const response = await this.client.send(new CreateDeploymentCommand({
applicationName, deploymentGroupName,
revision: {revisionType: 'AppSpecContent', appSpecContent: {content: JSON.stringify({version: '0.0', Resources: [{[input.functionName]: {Type: 'AWS::Lambda::Function', Properties: {Name: input.functionName, Alias: input.alias, CurrentVersion: input.currentVersion, TargetVersion: input.targetVersion}}}], ...(hooks.length ? {Hooks: hooks} : {})})}},
}))
if (!response.deploymentId) throw new RuntimeError('CodeDeploy did not return a deployment id')
return this.getCodeDeployDeployment(response.deploymentId)
}
async listCodeDeployDeployments(applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]> {
const response = await this.client.send(new ListDeploymentsCommand({applicationName, deploymentGroupName, includeOnlyStatuses: ['Created', 'Queued', 'InProgress', 'Succeeded', 'Failed', 'Stopped']}))
return Promise.all((response.deployments ?? []).map((id) => this.getCodeDeployDeployment(id)))
}
async getCodeDeployDeployment(id: string): Promise<CodeDeployDeployment> {
const response = await this.client.send(new GetDeploymentCommand({deploymentId: id}))
const deployment = response.deploymentInfo
if (!deployment?.deploymentId || !deployment.applicationName || !deployment.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment ${id} was not found`)
const revision = deployment.revision?.appSpecContent?.content
const revisionInfo = appSpecInfo(revision)
let actualTrafficPercent: number | null = null
if (revisionInfo.functionName && revisionInfo.alias && revisionInfo.targetVersion) {
const alias = await this.lambda.send(new GetAliasCommand({FunctionName: revisionInfo.functionName, Name: revisionInfo.alias}))
actualTrafficPercent = alias.FunctionVersion === revisionInfo.targetVersion ? 100 : Math.round((alias.RoutingConfig?.AdditionalVersionWeights?.[revisionInfo.targetVersion] ?? 0) * 100)
}
return {id: deployment.deploymentId, applicationName: deployment.applicationName, deploymentGroupName: deployment.deploymentGroupName, status: deployment.status ?? null, creator: deployment.creator ?? null, deploymentConfigName: deployment.deploymentConfigName ?? null, createTime: deployment.createTime, completeTime: deployment.completeTime, errorInformation: deployment.errorInformation ?? null, currentVersion: revisionInfo.currentVersion, targetVersion: revisionInfo.targetVersion, trafficPercent: actualTrafficPercent ?? trafficPercent(deployment.deploymentOverview)}
}
async stopCodeDeployDeployment(id: string, rollback: boolean): Promise<void> { await this.client.send(new StopDeploymentCommand({deploymentId: id, autoRollbackEnabled: rollback})) }
private async groupResource(applicationName: string, deploymentGroupName: string): Promise<CloudResource> {
const response = await this.client.send(new GetDeploymentGroupCommand({applicationName, deploymentGroupName}))
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()}}
}
}
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 splitId(id: string): [string, string] { const separator = id.indexOf('/'); if (separator <= 0 || separator === id.length - 1) throw new ValidationError('Deployment group id must be applicationName/deploymentGroupName'); return [id.slice(0, separator), id.slice(separator + 1)] }
function deploymentConfig(value: unknown): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.Lambda${choice}` }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
function hooksFor(input: {beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string}): Array<Record<string, string>> { const hooks: Array<Record<string, string>> = []; if (input.beforeAllowTrafficHook) hooks.push({BeforeAllowTraffic: input.beforeAllowTrafficHook}); if (input.afterAllowTrafficHook) hooks.push({AfterAllowTraffic: input.afterAllowTrafficHook}); return hooks }
function appSpecInfo(content?: string): {functionName: string | null; alias: string | null; currentVersion: string | null; targetVersion: string | null} { try { const parsed = JSON.parse(content ?? '{}') as {Resources?: Array<Record<string, {Properties?: {Name?: string; Alias?: string; CurrentVersion?: string; TargetVersion?: string}}>>}; const properties = parsed.Resources?.[0] && Object.values(parsed.Resources[0])[0]?.Properties; return {functionName: properties?.Name ?? null, alias: properties?.Alias ?? null, currentVersion: properties?.CurrentVersion ?? null, targetVersion: properties?.TargetVersion ?? null} } catch { return {functionName: null, alias: null, currentVersion: null, targetVersion: null} } }
function trafficPercent(overview?: {InProgress?: number; Succeeded?: number; Failed?: number; Skipped?: number; Ready?: number}): number | null { if (!overview) return null; const total = Object.values(overview).reduce((sum, value) => sum + (value ?? 0), 0); return total ? Math.round(((overview.Succeeded ?? 0) / total) * 100) : 0 }
@@ -0,0 +1,46 @@
import {describe, expect, test} from 'bun:test'
import {CreatePipelineCommand, GetPipelineCommand, GetPipelineStateCommand, ListPipelineExecutionsCommand, ListPipelinesCommand, StartPipelineExecutionCommand, type CodePipelineClient} from '@aws-sdk/client-codepipeline'
import {AwsCodePipelineAdapter} from './AwsCodePipelineAdapter'
function stub(handler: (command: object) => Record<string, unknown>) {
const sent: object[] = []
const client = {async send(command: object) { sent.push(command); return handler(command) }} as unknown as CodePipelineClient
return {client, sent}
}
describe('AwsCodePipelineAdapter', () => {
test('creates the documented S3, CodeBuild, S3 local 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'}})
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?.[1].actions?.[0].configuration).toEqual({ProjectName: 'build-app'})
expect(stages?.[2].actions?.[0].configuration).toEqual({BucketName: 'deploy', ObjectKey: 'releases/app.zip', Extract: 'true'})
})
test('uses native execution and state APIs and retains CodeBuild links', async () => {
const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: [{name: 'Build', actions: [{name: 'Build', actionTypeId: {category: 'Build' as const, owner: 'AWS' as const, provider: 'CodeBuild', version: '1'}, configuration: {ProjectName: 'build-app'}}]}]}
const {client, sent} = stub((command) => {
if (command instanceof StartPipelineExecutionCommand) return {pipelineExecutionId: 'execution-1'}
if (command instanceof ListPipelineExecutionsCommand) return {pipelineExecutionSummaries: [{pipelineExecutionId: 'execution-1', status: 'InProgress', startTime: new Date('2026-10-01T10:00:00.000Z')}]}
if (command instanceof GetPipelineCommand) return {pipeline}
if (command instanceof GetPipelineStateCommand) return {stageStates: [{stageName: 'Build', latestExecution: {status: 'InProgress'}, actionStates: [{actionName: 'Build', latestExecution: {status: 'InProgress', externalExecutionId: 'build-app:1'}}]}]}
return {}
})
const adapter = new AwsCodePipelineAdapter(client)
expect(await adapter.startPipeline('release')).toEqual({id: 'execution-1'})
expect((await adapter.listPipelineExecutions('release'))[0]).toMatchObject({id: 'execution-1', status: 'InProgress'})
expect((await adapter.getPipelineState('release')).stages[0].actions[0]).toMatchObject({name: 'Build', codeBuildProject: 'build-app', externalExecutionId: 'build-app:1'})
expect(sent.some((command) => command instanceof StartPipelineExecutionCommand)).toBe(true)
expect(sent.some((command) => command instanceof ListPipelineExecutionsCommand)).toBe(true)
expect(sent.some((command) => command instanceof GetPipelineStateCommand)).toBe(true)
})
test('lists native pipeline summaries', async () => {
const {client} = stub((command) => command instanceof ListPipelinesCommand ? {pipelines: [{name: 'release', version: 2, executionMode: 'SUPERSEDED'}]} : {})
const pipelines = await new AwsCodePipelineAdapter(client).list()
expect(pipelines[0]).toMatchObject({id: 'release', version: '2', metadata: {executionMode: 'SUPERSEDED'}})
})
})
@@ -0,0 +1,108 @@
import {
CreatePipelineCommand,
DeletePipelineCommand,
GetPipelineCommand,
GetPipelineStateCommand,
ListPipelinesCommand,
ListPipelineExecutionsCommand,
StartPipelineExecutionCommand,
type ActionState,
type CodePipelineClient,
type PipelineDeclaration,
} from '@aws-sdk/client-codepipeline'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsCodePipelineSchema} from '../cloud-spi/codepipelineSchema'
import type {PipelineExecutionSummary, PipelineState} from '../cloud-spi/codepipeline'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsCodePipelineAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'codepipeline' as const
constructor(private readonly client: CodePipelineClient) {}
schema(): ServiceSchema { return awsCodePipelineSchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const pipelines: CloudResource[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListPipelinesCommand(nextToken ? {nextToken} : {}))
pipelines.push(...(response.pipelines ?? []).map((pipeline) => toSummaryResource(pipeline)))
nextToken = response.nextToken
} while (nextToken)
const search = query.search?.trim().toLowerCase()
return pipelines.filter((pipeline) => !search || pipeline.name.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
const response = await this.client.send(new GetPipelineCommand({name: id}))
return response.pipeline ? toResource(response.pipeline, response.metadata) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = required(input.values.name, 'name')
const declaration: PipelineDeclaration = {
name,
roleArn: required(input.values.roleArn, 'roleArn'),
artifactStore: {type: 'S3', location: required(input.values.artifactBucket, 'artifactBucket')},
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'], [])),
],
}
const response = await this.client.send(new CreatePipelineCommand({pipeline: declaration}))
if (!response.pipeline) throw new RuntimeError(`CodePipeline did not return pipeline ${name}`)
return toResource(response.pipeline)
}
async delete(id: string): Promise<void> { await this.client.send(new DeletePipelineCommand({name: id})) }
async startPipeline(id: string): Promise<{id: string}> {
const response = await this.client.send(new StartPipelineExecutionCommand({name: id}))
if (!response.pipelineExecutionId) throw new RuntimeError(`CodePipeline did not return an execution for ${id}`)
return {id: response.pipelineExecutionId}
}
async listPipelineExecutions(id: string): Promise<PipelineExecutionSummary[]> {
const executions: PipelineExecutionSummary[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListPipelineExecutionsCommand(nextToken ? {pipelineName: id, nextToken} : {pipelineName: id}))
for (const execution of response.pipelineExecutionSummaries ?? []) {
if (execution.pipelineExecutionId) executions.push({id: execution.pipelineExecutionId, status: execution.status, statusSummary: execution.statusSummary, startTime: execution.startTime, lastUpdateTime: execution.lastUpdateTime})
}
nextToken = response.nextToken
} while (nextToken)
return executions
}
async getPipelineState(id: string): Promise<PipelineState> {
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))}))}
}
}
function toResource(pipeline: PipelineDeclaration, metadata?: {pipelineArn?: string; created?: Date; updated?: Date}): CloudResource {
const name = pipeline.name ?? ''
return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: regionFromArn(metadata?.pipelineArn), createdAt: metadata?.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {arn: metadata?.pipelineArn, roleArn: pipeline.roleArn, executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, artifactBucket: pipeline.artifactStore?.location, updatedAt: metadata?.updated?.toISOString()}}
}
function toSummaryResource(pipeline: {name?: string; version?: number; created?: Date; updated?: Date; executionMode?: string; pipelineType?: string}): CloudResource {
const name = pipeline.name ?? ''
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>) {
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}
}
function stage(name: string, item: ReturnType<typeof action>) { 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 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 }
@@ -8,8 +8,12 @@ import {
DescribeOrderableDBInstanceOptionsCommand,
ListTagsForResourceCommand,
ModifyDBInstanceCommand,
RebootDBInstanceCommand,
StartDBInstanceCommand,
StopDBInstanceCommand,
type RDSClient,
} from '@aws-sdk/client-rds'
import {ExecuteStatementCommand, type RDSDataClient} from '@aws-sdk/client-rds-data'
import {ConflictError, ValidationError} from '../cloud-spi/errors'
import {createRdsService} from '../services/rds'
import {mapAwsSdkError} from './awsErrors'
@@ -24,6 +28,9 @@ type RdsCommand =
| DescribeOrderableDBInstanceOptionsCommand
| ListTagsForResourceCommand
| ModifyDBInstanceCommand
| RebootDBInstanceCommand
| StartDBInstanceCommand
| StopDBInstanceCommand
type Responder = (command: RdsCommand) => unknown | Promise<unknown>
@@ -47,6 +54,17 @@ function adapterWith(responder: Responder) {
}
}
function recordingRdsData(responder: (command: ExecuteStatementCommand) => unknown | Promise<unknown>) {
const commands: ExecuteStatementCommand[] = []
const client = {
async send(command: ExecuteStatementCommand) {
commands.push(command)
return responder(command)
},
} as unknown as RDSDataClient
return {client, commands}
}
function instanceResponse(command: CreateDBInstanceCommand) {
return {
DBInstance: {
@@ -69,6 +87,47 @@ const validCreateValues = {
}
describe('AwsDatabaseAdapter', () => {
test('explores PostgreSQL databases through the RDS Data API without console credentials', async () => {
const {client, commands} = recordingRdsData((command) => {
expect(command.input.resourceArn).toBe('arn:aws:rds:us-east-1:000000000000:db:orders-db')
expect(command.input.database).toBe('orders')
expect(command.input.sql).toContain('FROM pg_database')
return {
columnMetadata: [{name: 'name', typeName: 'varchar'}, {name: 'state', typeName: 'varchar'}, {name: 'isSystem', typeName: 'bool'}],
records: [[{stringValue: 'orders'}, {stringValue: 'ONLINE'}, {booleanValue: false}]],
}
})
const {client: rds, commands: rdsCommands} = recordingRds((command) => {
expect(command).toBeInstanceOf(DescribeDBInstancesCommand)
return {DBInstances: [{
DBInstanceIdentifier: 'orders-db',
DBInstanceArn: 'arn:aws:rds:us-east-1:000000000000:db:orders-db',
Engine: 'postgres',
DBName: 'orders',
VpcSecurityGroups: [],
}]}
})
const adapter = new AwsDatabaseAdapter(createRdsService(rds), rds, client)
await expect(adapter.listSqlDatabases('orders-db', {username: '', password: ''})).resolves.toEqual([{
name: 'orders', state: 'ONLINE', createdAt: null, isSystem: false,
}])
expect(rdsCommands).toHaveLength(1)
expect(commands).toHaveLength(1)
expect(commands[0].input.secretArn).toContain('floci-ui-rds-data')
})
test('rejects SQL exploration for unsupported RDS engines', async () => {
const {client: rds} = recordingRds(() => ({DBInstances: [{
DBInstanceIdentifier: 'maria-db', Engine: 'mariadb', VpcSecurityGroups: [],
}]}))
const {client} = recordingRdsData(() => ({}))
const adapter = new AwsDatabaseAdapter(createRdsService(rds), rds, client)
await expect(adapter.querySql('maria-db', {username: '', password: '', database: 'app'}, 'SELECT 1'))
.rejects.toBeInstanceOf(ValidationError)
})
test('keeps list search behavior intact', async () => {
const {adapter} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeDBInstancesCommand)
@@ -285,6 +344,25 @@ describe('AwsDatabaseAdapter', () => {
})
})
test('sends RDS lifecycle commands for an instance', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
await adapter.start('orders-db')
await adapter.stop('orders-db')
await adapter.reboot('orders-db')
expect(commands.map((command) => command.constructor)).toEqual([
StartDBInstanceCommand,
StopDBInstanceCommand,
RebootDBInstanceCommand,
])
expect(commands.map((command) => command.input)).toEqual([
{DBInstanceIdentifier: 'orders-db'},
{DBInstanceIdentifier: 'orders-db'},
{DBInstanceIdentifier: 'orders-db'},
])
})
test('lists and normalizes database snapshots', async () => {
const {adapter, commands} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand)
@@ -1,6 +1,7 @@
import {ValidationError} from '../cloud-spi/errors'
import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds'
import {rds as defaultRds} from '../aws'
import {ExecuteStatementCommand, type Field, type RDSDataClient} from '@aws-sdk/client-rds-data'
import {rds as defaultRds, awsClients} from '../aws'
import {awsDatabaseSchema} from '../cloud-spi/databaseSchema'
import type {
CloudResource,
@@ -10,13 +11,17 @@ import type {
DatabaseSnapshot,
ResourceQuery,
ServiceSchema,
SqlConnectionInput,
SqlDatabase,
SqlQueryResult,
SqlTable,
UpdateResourceInput,
} from '../cloud-spi/types'
import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds'
type RdsServiceShape = Pick<
typeof rdsService,
'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses'
'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'startInstance' | 'stopInstance' | 'rebootInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses'
>
export class AwsDatabaseAdapter implements CloudServiceAdapter {
@@ -26,6 +31,7 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
constructor(
private readonly rdsService_: RdsServiceShape = rdsService,
private readonly rds: RDSClient = defaultRds,
private readonly rdsData: RDSDataClient = awsClients.rdsData,
) {}
schema(): ServiceSchema {
@@ -118,6 +124,18 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
await this.rdsService_.deleteInstance(id)
}
async start(id: string): Promise<void> {
await this.rdsService_.startInstance(id)
}
async stop(id: string): Promise<void> {
await this.rdsService_.stopInstance(id)
}
async reboot(id: string): Promise<void> {
await this.rdsService_.rebootInstance(id)
}
async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> {
const normalizedIdentifier = stringValue(instanceIdentifier) || undefined
return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot)
@@ -136,6 +154,73 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine)
}
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
const instance = await this.rdsService_.describeInstance(serverId)
const engine = sqlEngine(instance.engine)
const result = await this.execute(instance, defaultDatabase(instance, engine), databaseListQuery(engine))
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
const name = stringValue(row.name)
if (!name) return []
return [{
name,
state: stringValue(row.state) || 'ONLINE',
createdAt: null,
isSystem: row.isSystem === true,
}]
})
}
async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
const instance = await this.rdsService_.describeInstance(serverId)
const engine = sqlEngine(instance.engine)
const database = sqlDatabase(connection, instance, engine)
const result = await this.execute(instance, database, tableListQuery(engine))
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
const schema = stringValue(row.schemaName)
const name = stringValue(row.name)
if (!schema || !name) return []
return [{
schema,
name,
type: row.objectType === 'view' ? 'view' as const : 'table' as const,
rowCount: integerValue(row.rowCount),
}]
})
}
async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
if (!query.trim()) throw new ValidationError('SQL query is required')
const instance = await this.rdsService_.describeInstance(serverId)
const engine = sqlEngine(instance.engine)
return this.execute(instance, sqlDatabase(connection, instance, engine), query)
}
private async execute(instance: RdsInstance, database: string, sql: string): Promise<SqlQueryResult> {
if (!instance.arn) throw new ValidationError('DB instance ARN is not available')
const startedAt = performance.now()
const response = await this.rdsData.send(new ExecuteStatementCommand({
resourceArn: instance.arn,
// Floci falls back to the RDS instance credentials when this local
// placeholder does not resolve to a Secrets Manager secret.
secretArn: 'arn:aws:secretsmanager:us-east-1:000000000000:secret:floci-ui-rds-data',
database,
sql,
includeResultMetadata: true,
}))
const columns = (response.columnMetadata ?? []).map((column, index) => ({
name: column.name ?? `column_${index + 1}`,
type: String(column.typeName ?? column.type ?? 'unknown'),
}))
const rows = (response.records ?? []).map((record) => Object.fromEntries(
record.map((field, index) => [columns[index]?.name ?? `column_${index + 1}`, fieldValue(field)]),
))
return {
resultSets: columns.length > 0 ? [{columns, rows, truncated: false}] : [],
rowsAffected: [response.numberOfRecordsUpdated ?? 0],
durationMs: Math.round(performance.now() - startedAt),
}
}
private async toResource(instance: RdsInstance): Promise<CloudResource> {
const tags = instance.arn ? await this.getTags(instance.arn) : []
const vpcSecurityGroupIds = (instance.vpcSecurityGroups ?? [])
@@ -192,6 +277,75 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
}
}
const POSTGRES_DATABASES_QUERY = `
SELECT datname AS name,
CASE WHEN datallowconn THEN 'ONLINE' ELSE 'OFFLINE' END AS state,
datname IN ('postgres', 'template0', 'template1') AS "isSystem"
FROM pg_database
WHERE datallowconn
ORDER BY "isSystem" DESC, datname`
const POSTGRES_TABLES_QUERY = `
SELECT namespace.nspname AS "schemaName", relation.relname AS name,
CASE WHEN relation.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END AS "objectType",
CASE WHEN relation.relkind IN ('r', 'p') THEN relation.reltuples::bigint ELSE NULL END AS "rowCount"
FROM pg_class AS relation
INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace
WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema')
AND namespace.nspname NOT LIKE 'pg_toast%'
AND relation.relkind IN ('r', 'p', 'v', 'm')
ORDER BY namespace.nspname, relation.relname`
const MYSQL_DATABASES_QUERY = `
SELECT schema_name AS name, 'ONLINE' AS state,
schema_name IN ('information_schema', 'mysql', 'performance_schema', 'sys') AS isSystem
FROM information_schema.schemata
ORDER BY isSystem DESC, schema_name`
const MYSQL_TABLES_QUERY = `
SELECT table_schema AS schemaName, table_name AS name,
CASE WHEN table_type = 'VIEW' THEN 'view' ELSE 'table' END AS objectType,
table_rows AS rowCount
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY table_name`
function sqlEngine(engine: string | undefined): 'postgresql' | 'mysql' {
if (engine === 'postgres') return 'postgresql'
if (engine === 'mysql') return 'mysql'
throw new ValidationError('SQL exploration is available only for PostgreSQL and MySQL RDS instances')
}
function defaultDatabase(instance: RdsInstance, engine: 'postgresql' | 'mysql'): string {
return instance.dbName || (engine === 'postgresql' ? 'postgres' : 'mysql')
}
function sqlDatabase(connection: SqlConnectionInput, instance: RdsInstance, engine: 'postgresql' | 'mysql'): string {
return stringValue(connection.database) || defaultDatabase(instance, engine)
}
function databaseListQuery(engine: 'postgresql' | 'mysql'): string {
return engine === 'postgresql' ? POSTGRES_DATABASES_QUERY : MYSQL_DATABASES_QUERY
}
function tableListQuery(engine: 'postgresql' | 'mysql'): string {
return engine === 'postgresql' ? POSTGRES_TABLES_QUERY : MYSQL_TABLES_QUERY
}
function integerValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
if (typeof value === 'string' && /^-?\d+$/.test(value)) return Number(value)
return null
}
function fieldValue(field: Field): unknown {
if (field.isNull) return null
if (field.stringValue !== undefined) return field.stringValue
if (field.longValue !== undefined) return Number(field.longValue)
if (field.doubleValue !== undefined) return field.doubleValue
if (field.booleanValue !== undefined) return field.booleanValue
if (field.blobValue !== undefined) return Buffer.from(field.blobValue).toString('base64')
if (field.arrayValue !== undefined) return field.arrayValue
return null
}
const INSTANCE_IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/
const SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/