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
+5 -1
View File
@@ -14,9 +14,12 @@
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-codebuild": "^3.1076.0",
"@aws-sdk/client-codedeploy": "^3.1144.0",
"@aws-sdk/client-codepipeline": "^3.1076.0",
"@aws-sdk/client-dynamodb": "^3.1088.0",
"@aws-sdk/client-ecr": "^3.1076.0",
"@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-ecr": "^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",
@@ -24,6 +27,7 @@
"@aws-sdk/client-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1076.0",
"@aws-sdk/client-secrets-manager": "^3.1076.0",
"@aws-sdk/client-sfn": "^3.1096.0",
@@ -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])?$/
+12
View File
@@ -3,6 +3,7 @@ import { LambdaClient } from "@aws-sdk/client-lambda";
import { EKSClient } from "@aws-sdk/client-eks";
import { EC2Client } from "@aws-sdk/client-ec2";
import { RDSClient } from "@aws-sdk/client-rds";
import { RDSDataClient } from "@aws-sdk/client-rds-data";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import { SSMClient } from "@aws-sdk/client-ssm";
@@ -18,6 +19,9 @@ import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns";
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";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -52,6 +56,7 @@ export type AwsClients = {
eks: EKSClient;
ec2: EC2Client;
rds: RDSClient;
rdsData: RDSDataClient;
secretsManager: SecretsManagerClient;
logs: CloudWatchLogsClient;
ssm: SSMClient;
@@ -67,6 +72,9 @@ export type AwsClients = {
appConfig: AppConfigClient;
sns: SNSClient;
ecr: ECRClient;
codebuild: CodeBuildClient;
codepipeline: CodePipelineClient;
codedeploy: CodeDeployClient;
};
export type AwsClientName = keyof AwsClients;
@@ -85,6 +93,7 @@ function buildClients(accountId: string): AwsClients {
eks: new EKSClient(base),
ec2: new EC2Client(base),
rds: new RDSClient(base),
rdsData: new RDSDataClient(base),
secretsManager: new SecretsManagerClient(base),
logs: new CloudWatchLogsClient(base),
ssm: new SSMClient(base),
@@ -100,6 +109,9 @@ function buildClients(accountId: string): AwsClients {
appConfig: new AppConfigClient(base),
sns: new SNSClient(base),
ecr: new ECRClient(base),
codebuild: new CodeBuildClient(base),
codepipeline: new CodePipelineClient(base),
codedeploy: new CodeDeployClient(base),
};
}
+31
View File
@@ -0,0 +1,31 @@
export interface CodeBuildEnvironmentVariableOverride {
name: string
value: string
type?: 'PLAINTEXT' | 'PARAMETER_STORE' | 'SECRETS_MANAGER'
}
export interface StartCodeBuildInput {
environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]
}
export interface CodeBuildPhase {
phaseType: string
phaseStatus?: string
startTime?: Date
endTime?: Date
contexts: Array<{statusCode?: string; message?: string}>
}
export interface CodeBuildBuild {
id: string
arn?: string
projectName: string
buildNumber?: number
buildStatus?: string
currentPhase?: string
startTime?: Date
endTime?: Date
phases: CodeBuildPhase[]
logGroupName?: string
logStreamName?: string
}
@@ -0,0 +1,43 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const projectColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'environmentType', label: 'Environment', path: 'metadata.environmentType', format: 'badge'},
{name: 'computeType', label: 'Compute', path: 'metadata.computeType', format: 'badge'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const projectFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
/** The console exposes only source and artifact modes the local runtime can execute. */
export function awsCodeBuildSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'codebuild',
displayName: 'AWS CodeBuild',
fields: [
{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',
defaultValue: 'aws/codebuild/standard:7.0',
description: 'AWS CodeBuild managed image identifier.',
},
{
name: 'computeType', label: 'Compute type', type: 'select', required: true, group: 'Environment', defaultValue: 'BUILD_GENERAL1_SMALL',
options: [{label: 'General 1 small', value: 'BUILD_GENERAL1_SMALL'}],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List projects', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect project', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create project', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete project', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: projectFilters,
columns: projectColumns,
}
}
+23
View File
@@ -0,0 +1,23 @@
export interface CodeDeployDeployment {
id: string
applicationName: string
deploymentGroupName: string
status: string | null
creator: string | null
deploymentConfigName: string | null
createTime: Date | undefined
completeTime: Date | undefined
errorInformation: {code?: string; message?: string} | null
targetVersion: string | null
currentVersion: string | null
trafficPercent: number | null
}
export interface StartCodeDeployDeploymentInput {
functionName: string
alias: string
currentVersion: string
targetVersion: string
beforeAllowTrafficHook?: string
afterAllowTrafficHook?: string
}
@@ -0,0 +1,23 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [
{name: 'name', label: 'Deployment group'},
{name: 'applicationName', label: 'Application', path: 'metadata.applicationName'},
{name: 'deploymentConfigName', label: 'Configuration', path: 'metadata.deploymentConfigName', format: 'badge'},
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
]
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsCodeDeploySchema(): ServiceSchema {
return {
cloud: 'aws', service: 'codedeploy', displayName: 'AWS CodeDeploy', fields: [], actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [
{name: 'list', label: 'List deployment groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect deployment group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create application and group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete deployment group', enabled: true, status: 'available', runtimeRequired: true},
]},
filters, columns,
}
}
@@ -0,0 +1,31 @@
export interface PipelineExecutionSummary {
id: string
status?: string
statusSummary?: string
startTime?: Date
lastUpdateTime?: Date
}
export interface PipelineActionState {
name: string
status?: string
summary?: string
lastStatusChange?: Date
externalExecutionId?: string
externalExecutionUrl?: string
entityUrl?: string
revisionUrl?: string
codeBuildProject?: string
codeDeployDeploymentId?: string
}
export interface PipelineStageState {
name: string
status?: string
lastStatusChange?: Date
actions: PipelineActionState[]
}
export interface PipelineState {
stages: PipelineStageState[]
}
@@ -0,0 +1,33 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'executionMode', label: 'Execution mode', path: 'metadata.executionMode', format: 'badge'},
{name: 'version', label: 'Version', format: 'code'},
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
]
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsCodePipelineSchema(): ServiceSchema {
return {
cloud: 'aws', service: 'codepipeline', displayName: 'AWS CodePipeline', fields: [
{name: 'name', label: 'Pipeline name', type: 'text', required: true, group: 'Pipeline'},
{name: 'roleArn', label: 'Service role ARN', type: 'text', required: true, group: 'Pipeline'},
{name: 'artifactBucket', label: 'Artifact S3 bucket', type: 'text', required: true, group: 'Artifacts'},
{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'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [
{name: 'list', label: 'List pipelines', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect pipeline', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create pipeline', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete pipeline', enabled: true, status: 'available', runtimeRequired: true},
]},
filters, columns,
}
}
@@ -114,6 +114,9 @@ export function awsDatabaseSchema(): ServiceSchema {
{name: 'update', label: 'Update DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', label: 'Start DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', label: 'Stop DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'reboot', label: 'Reboot DB instance', enabled: true, status: 'available', runtimeRequired: true},
],
databaseActions: [
{
@@ -77,6 +77,24 @@ export const SERVICE_CATALOG = {
group: 'Compute',
order: 40,
},
codebuild: {
displayName: 'CodeBuild',
iconKey: 'codebuild',
group: 'Compute',
order: 50,
},
codepipeline: {
displayName: 'CodePipeline',
iconKey: 'codepipeline',
group: 'Integration',
order: 25,
},
codedeploy: {
displayName: 'CodeDeploy',
iconKey: 'codedeploy',
group: 'Integration',
order: 26,
},
storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10},
ecr: {
displayName: 'Elastic Container Registry',
+16 -4
View File
@@ -1,6 +1,9 @@
// Derived from SERVICE_CATALOG so a new catalog row is a new service type.
// This type-only cycle with serviceCatalog.ts is erased at compile time.
import type {CloudServiceType, ServiceGroup} from './serviceCatalog'
import type {CodeBuildBuild, StartCodeBuildInput} from './codebuild'
import type {PipelineExecutionSummary, PipelineState} from './codepipeline'
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from './codedeploy'
import type {
ChildItem,
CollectionActionName,
@@ -103,6 +106,7 @@ export interface FieldSchema {
* that a capability block can describe but that are not table-level controls.
*/
export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect'
export type ResourceLifecycleAction = 'start' | 'stop' | 'reboot'
export type ResourceActionName =
| 'list'
| 'create'
@@ -112,9 +116,7 @@ export type ResourceActionName =
| 'encrypt'
| 'decrypt'
| 'invoke'
| 'start'
| 'stop'
| 'reboot'
| ResourceLifecycleAction
| 'updateTags'
export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy'
export type DatabaseActionName = 'listSnapshots' | 'createSnapshot'
@@ -266,7 +268,7 @@ export interface SqlConnectionInput {
username: string
password: string
database?: string
engine?: 'azure-sql' | 'postgresql'
engine?: 'azure-sql' | 'postgresql' | 'mysql'
}
export interface SqlDatabase {
@@ -854,4 +856,14 @@ export interface CloudServiceAdapter {
deleteAppConfigDeploymentStrategy?(strategyId: string): Promise<void>
startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment>
getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise<AppConfigDeployment | null>
startBuild?(projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild>
listBuilds?(projectName: string): Promise<CodeBuildBuild[]>
getBuild?(id: string): Promise<CodeBuildBuild | null>
startPipeline?(id: string): Promise<{id: string}>
listPipelineExecutions?(id: string): Promise<PipelineExecutionSummary[]>
getPipelineState?(id: string): Promise<PipelineState>
startCodeDeployDeployment?(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment>
listCodeDeployDeployments?(applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]>
getCodeDeployDeployment?(id: string): Promise<CodeDeployDeployment>
stopCodeDeployDeployment?(id: string, rollback: boolean): Promise<void>
}
+3
View File
@@ -178,6 +178,9 @@ describe('adapter capability advertisements match the runtime reality', () => {
{name: 'update', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', enabled: true, status: 'available', runtimeRequired: true},
{name: 'reboot', enabled: true, status: 'available', runtimeRequired: true},
])
expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'create')?.label).toBe('Create DB instance')
expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'update')?.label).toBe('Update DB instance')
+7 -1
View File
@@ -39,6 +39,9 @@ import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter'
import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter'
import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter'
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 {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -61,7 +64,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsStorageAdapter(clients.s3, clients.lambda, resolveAccountId(accountId)),
new AwsDynamoDbAdapter(clients.dynamodb),
new AwsEksAdapter(createEksService(clients.eks)),
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds),
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds, clients.rdsData),
new AwsComputeAdapter(ec2Service),
new AwsNetworkingAdapter(ec2Service),
new AwsServerlessAdapter(clients.lambda, clients.eventbridge),
@@ -80,6 +83,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsAppConfigAdapter(clients.appConfig),
new AwsSnsAdapter(clients.sns),
new AwsEcrAdapter(clients.ecr),
new AwsCodeBuildAdapter(clients.codebuild),
new AwsCodePipelineAdapter(clients.codepipeline),
new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+21
View File
@@ -448,6 +448,27 @@ describe('database snapshot routes', () => {
})
})
describe('resource lifecycle routes', () => {
test('routes start, stop, and reboot through the generic adapter lifecycle operations', async () => {
const calls: string[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'database',
schema: awsDatabaseSchema,
start: async (id) => { calls.push(`start:${id}`) },
stop: async (id) => { calls.push(`stop:${id}`) },
reboot: async (id) => { calls.push(`reboot:${id}`) },
})])
for (const action of ['start', 'stop', 'reboot']) {
const response = await app.request(`/api/clouds/aws/services/database/resources/orders-db/${action}`, {method: 'POST'})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ok: true})
}
expect(calls).toEqual(['start:orders-db', 'stop:orders-db', 'reboot:orders-db'])
})
})
describe('cloud schema routes', () => {
test('returns AWS storage schema', async () => {
const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema')
+101
View File
@@ -863,6 +863,71 @@ 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 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)
})
})
app.get('/: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 () => c.json(await svc(c).listCodeBuilds('aws', c.req.param('id'))))
})
app.get('/:cloud/services/codebuild/resources/:id/builds/:buildId', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const build = await svc(c).getCodeBuild('aws', c.req.param('buildId'))
return build ? c.json(build) : c.json({error: 'Build not found'}, 404)
})
})
app.post('/:cloud/services/codepipeline/resources/:id/executions', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).startPipeline('aws', c.req.param('id')), 201))
})
app.post('/:cloud/services/codedeploy/resources/:id/deployments', 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<{functionName?: unknown; alias?: unknown; currentVersion?: unknown; targetVersion?: unknown; beforeAllowTrafficHook?: unknown; afterAllowTrafficHook?: unknown}>(c)
const [applicationName, deploymentGroupName] = codeDeployGroupId(c.req.param('id'))
if (typeof body.functionName !== 'string' || typeof body.alias !== 'string' || typeof body.currentVersion !== 'string' || typeof body.targetVersion !== 'string') throw new ValidationError('functionName, alias, currentVersion, and targetVersion are required')
return c.json(await svc(c).startCodeDeployDeployment('aws', applicationName, deploymentGroupName, {functionName: body.functionName, alias: body.alias, currentVersion: body.currentVersion, targetVersion: body.targetVersion, beforeAllowTrafficHook: typeof body.beforeAllowTrafficHook === 'string' ? body.beforeAllowTrafficHook : undefined, afterAllowTrafficHook: typeof body.afterAllowTrafficHook === 'string' ? body.afterAllowTrafficHook : undefined}), 201)
})
})
app.get('/:cloud/services/codedeploy/resources/:id/deployments', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { const [applicationName, deploymentGroupName] = codeDeployGroupId(c.req.param('id')); return c.json(await svc(c).listCodeDeployDeployments('aws', applicationName, deploymentGroupName)) })
})
app.get('/:cloud/services/codedeploy/deployments/:deploymentId', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getCodeDeployDeployment('aws', c.req.param('deploymentId'))))
})
app.post('/:cloud/services/codedeploy/deployments/:deploymentId/stop', 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<{rollback?: unknown}>(c); await svc(c).stopCodeDeployDeployment('aws', c.req.param('deploymentId'), body.rollback === true); return c.json({ok: true}) })
})
app.get('/:cloud/services/codepipeline/resources/:id/executions', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listPipelineExecutions('aws', c.req.param('id'))))
})
app.get('/:cloud/services/codepipeline/resources/:id/state', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getPipelineState('aws', c.req.param('id'))))
})
app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -1110,6 +1175,36 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.post('/:cloud/services/:service/resources/:id/start', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).startResource(cloud, serviceType, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/:service/resources/:id/stop', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).stopResource(cloud, serviceType, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/:service/resources/:id/reboot', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).rebootResource(cloud, serviceType, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/notifications/resources/:id/publish', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -1366,6 +1461,12 @@ function pageQuery(c: Context): PageQuery {
return {cursor: c.req.query('cursor'), limit: clampLimit(c.req.query('limit'))}
}
function codeDeployGroupId(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)]
}
async function withRuntime(c: Context, handler: () => Promise<Response>): Promise<Response> {
try {
return await handler()
@@ -27,3 +27,25 @@ describe('CloudProxyService API Gateway CORS', () => {
}}])
})
})
describe('CloudProxyService resource lifecycle', () => {
test('forwards lifecycle actions to the registered adapter', async () => {
const calls: string[] = []
const adapter: CloudServiceAdapter = {
cloud: 'aws', service: 'apigateway', schema: awsApiGatewaySchema,
list: async () => [], get: async () => null, create: async () => ({
id: 'api', name: 'api', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: null, metadata: {},
}), delete: async () => {},
start: async (id) => { calls.push(`start:${id}`) },
stop: async (id) => { calls.push(`stop:${id}`) },
reboot: async (id) => { calls.push(`reboot:${id}`) },
}
const service = new CloudProxyService(new CloudAdapterRegistry([adapter]))
await service.startResource('aws', 'apigateway', 'api-123')
await service.stopResource('aws', 'apigateway', 'api-123')
await service.rebootResource('aws', 'apigateway', 'api-123')
expect(calls).toEqual(['start:api-123', 'stop:api-123', 'reboot:api-123'])
})
})
@@ -86,6 +86,9 @@ import type {
import {NotSupportedError} from '../cloud-spi/errors'
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
import type {StartWorkflowExecutionInput, WorkflowExecutionDetail, WorkflowExecutionHistoryPage, WorkflowExecutionSummary} from '../cloud-spi/workflowExecutions'
import type {CodeBuildBuild, StartCodeBuildInput} from '../cloud-spi/codebuild'
import type {PipelineExecutionSummary, PipelineState} from '../cloud-spi/codepipeline'
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from '../cloud-spi/codedeploy'
import {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog'
import {toHttpError} from '../cloud-spi/errors'
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
@@ -482,6 +485,24 @@ export class CloudProxyService {
await this.requireAdapter(cloud, service).delete(id)
}
async startResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.start) throw new NotSupportedError(`Resource start is not supported for ${cloud}/${service}`)
await adapter.start(id)
}
async stopResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.stop) throw new NotSupportedError(`Resource stop is not supported for ${cloud}/${service}`)
await adapter.stop(id)
}
async rebootResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.reboot) throw new NotSupportedError(`Resource reboot is not supported for ${cloud}/${service}`)
await adapter.reboot(id)
}
async encryptKms(cloud: CloudProvider, keyId: string, input: KmsEncryptInput): Promise<KmsEncryptResult> {
const adapter = this.requireAdapter(cloud, 'kms')
if (!adapter.encrypt) throw new NotSupportedError(`${cloud}/kms encrypt is not supported`)
@@ -852,6 +873,46 @@ export class CloudProxyService {
await this.workflowAdapter(cloud).stopExecution(executionArn)
}
async startCodeBuild(cloud: CloudProvider, projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild> {
return this.codeBuildAdapter(cloud).startBuild!(projectName, input)
}
async listCodeBuilds(cloud: CloudProvider, projectName: string): Promise<CodeBuildBuild[]> {
return this.codeBuildAdapter(cloud).listBuilds!(projectName)
}
async getCodeBuild(cloud: CloudProvider, id: string): Promise<CodeBuildBuild | null> {
return this.codeBuildAdapter(cloud).getBuild!(id)
}
async startPipeline(cloud: CloudProvider, id: string): Promise<{id: string}> {
return this.codePipelineAdapter(cloud).startPipeline!(id)
}
async listPipelineExecutions(cloud: CloudProvider, id: string): Promise<PipelineExecutionSummary[]> {
return this.codePipelineAdapter(cloud).listPipelineExecutions!(id)
}
async getPipelineState(cloud: CloudProvider, id: string): Promise<PipelineState> {
return this.codePipelineAdapter(cloud).getPipelineState!(id)
}
async startCodeDeployDeployment(cloud: CloudProvider, applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> {
return this.codeDeployAdapter(cloud).startCodeDeployDeployment!(applicationName, deploymentGroupName, input)
}
async listCodeDeployDeployments(cloud: CloudProvider, applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]> {
return this.codeDeployAdapter(cloud).listCodeDeployDeployments!(applicationName, deploymentGroupName)
}
async getCodeDeployDeployment(cloud: CloudProvider, id: string): Promise<CodeDeployDeployment> {
return this.codeDeployAdapter(cloud).getCodeDeployDeployment!(id)
}
async stopCodeDeployDeployment(cloud: CloudProvider, id: string, rollback: boolean): Promise<void> {
await this.codeDeployAdapter(cloud).stopCodeDeployDeployment!(id, rollback)
}
private workflowAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'workflows')
if (!('startExecution' in adapter) || !('listExecutions' in adapter) || !('describeExecution' in adapter) || !('getExecutionHistory' in adapter) || !('stopExecution' in adapter)) {
@@ -860,6 +921,30 @@ export class CloudProxyService {
return adapter as import('../adapter-aws/AwsStepFunctionsAdapter').AwsStepFunctionsAdapter
}
private codeBuildAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'codebuild')
if (!adapter.startBuild || !adapter.listBuilds || !adapter.getBuild) {
throw new NotSupportedError(`CodeBuild builds are not supported for ${cloud}`)
}
return adapter
}
private codePipelineAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'codepipeline')
if (!adapter.startPipeline || !adapter.listPipelineExecutions || !adapter.getPipelineState) {
throw new NotSupportedError(`CodePipeline executions are not supported for ${cloud}`)
}
return adapter
}
private codeDeployAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'codedeploy')
if (!adapter.startCodeDeployDeployment || !adapter.listCodeDeployDeployments || !adapter.getCodeDeployDeployment || !adapter.stopCodeDeployDeployment) {
throw new NotSupportedError(`CodeDeploy deployments are not supported for ${cloud}`)
}
return adapter
}
private requireAdapter(cloud: CloudProvider, service: CloudServiceType) {
const adapter = this.registry.get(cloud, service)
if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`)
+21
View File
@@ -6,6 +6,9 @@ import {
DescribeDBSnapshotsCommand,
DescribeOrderableDBInstanceOptionsCommand,
ModifyDBInstanceCommand,
RebootDBInstanceCommand,
StartDBInstanceCommand,
StopDBInstanceCommand,
type CreateDBInstanceCommandInput,
type DBInstance,
type DBSnapshot,
@@ -194,6 +197,24 @@ export function createRdsService(client: RDSClient = awsClients.rds) {
);
},
async startInstance(identifier: string): Promise<void> {
await client.send(
new StartDBInstanceCommand({ DBInstanceIdentifier: identifier }),
);
},
async stopInstance(identifier: string): Promise<void> {
await client.send(
new StopDBInstanceCommand({ DBInstanceIdentifier: identifier }),
);
},
async rebootInstance(identifier: string): Promise<void> {
await client.send(
new RebootDBInstanceCommand({ DBInstanceIdentifier: identifier }),
);
},
async listSnapshots(instanceIdentifier?: string): Promise<RdsSnapshot[]> {
const snapshots: RdsSnapshot[] = [];
let marker: string | undefined;