Files
floci-ui/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts
T
2026-10-05 11:06:57 -04:00

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 }