CI/CD - CodeBuild + CodeDeploy + CodePipeline
This commit is contained in:
@@ -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])?$/
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user