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 { 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 { const [applicationName, deploymentGroupName] = splitId(id) return this.groupResource(applicationName, deploymentGroupName) } async create(input: CreateResourceInput): Promise { 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 { 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> { 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 { 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 { 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 { 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 { await this.client.send(new StopDeploymentCommand({deploymentId: id, autoRollbackEnabled: rollback})) } private async groupResource(applicationName: string, deploymentGroupName: string): Promise { 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> { const hooks: Array> = []; 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>}; 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 }