CI/CD - CodeBuild + CodeDeploy + CodePipeline
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user