148 lines
12 KiB
TypeScript
148 lines
12 KiB
TypeScript
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 computePlatform = optional(input.values.computePlatform) ?? 'Lambda'
|
|
if (computePlatform !== 'Lambda' && computePlatform !== 'ECS') throw new ValidationError('computePlatform must be Lambda or ECS')
|
|
const applications = await this.client.send(new ListApplicationsCommand({}))
|
|
if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform}))
|
|
await this.client.send(new CreateDeploymentGroupCommand({
|
|
applicationName,
|
|
deploymentGroupName,
|
|
serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'),
|
|
deploymentConfigName: deploymentConfig(input.values.deploymentConfig, computePlatform),
|
|
deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
|
|
autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']},
|
|
...(computePlatform === 'ECS' ? {
|
|
ecsServices: [{
|
|
clusterName: required(input.values.clusterName, 'clusterName'),
|
|
serviceName: required(input.values.serviceName, 'serviceName'),
|
|
}],
|
|
loadBalancerInfo: {targetGroupPairInfoList: [{
|
|
targetGroups: [
|
|
{name: required(input.values.blueTargetGroupName, 'blueTargetGroupName')},
|
|
{name: required(input.values.greenTargetGroupName, 'greenTargetGroupName')},
|
|
],
|
|
prodTrafficRoute: {listenerArns: [required(input.values.productionListenerArn, 'productionListenerArn')]},
|
|
}]},
|
|
} : {}),
|
|
}))
|
|
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
|
|
const targetGroupPair = group.loadBalancerInfo?.targetGroupPairInfoList?.[0]
|
|
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, computePlatform: group.computePlatform ?? ((group.ecsServices ?? []).length ? 'ECS' : 'Lambda'), serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), targetGroupNames: (targetGroupPair?.targetGroups ?? []).map((targetGroup) => targetGroup.name).filter(Boolean), productionListenerArns: targetGroupPair?.prodTrafficRoute?.listenerArns ?? [], 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, computePlatform: 'Lambda' | 'ECS'): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.${computePlatform}${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 }
|