import { BatchDeleteImageCommand, CreateRepositoryCommand, DeleteRepositoryCommand, DescribeImagesCommand, DescribeRepositoriesCommand, type ECRClient, type ImageDetail, type Repository, } from '@aws-sdk/client-ecr' import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' import {RuntimeError, ValidationError} from '../cloud-spi/errors' import {ecr as defaultEcr} from '../aws' import {awsEcrSchema} from '../cloud-spi/ecrSchema' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' const ECR_REPOSITORY_NAME = /^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*$/ export class AwsEcrAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'ecr' as const readonly items: ItemStoreAdapter constructor(private readonly ecr: ECRClient = defaultEcr) { this.items = new EcrImageStore(ecr) } schema(): ServiceSchema { return awsEcrSchema() } async list(query: ResourceQuery = {}): Promise { const repositories: Repository[] = [] let nextToken: string | undefined do { const response = await this.ecr.send(new DescribeRepositoriesCommand({nextToken})) repositories.push(...(response.repositories ?? [])) nextToken = response.nextToken } while (nextToken) const search = query.search?.trim().toLowerCase() return repositories .filter((repository) => !search || repository.repositoryName?.toLowerCase().includes(search)) .map(toResource) } async get(id: string): Promise { try { const response = await this.ecr.send(new DescribeRepositoriesCommand({repositoryNames: [id]})) const repository = response.repositories?.[0] return repository ? toResource(repository) : null } catch (error) { if (isRepositoryNotFound(error)) return null throw error } } async create(input: CreateResourceInput): Promise { const repositoryName = stringValue(input.values.repositoryName) if (!repositoryName) throw new ValidationError('repositoryName is required') if (repositoryName.length < 2 || repositoryName.length > 256 || !ECR_REPOSITORY_NAME.test(repositoryName)) { throw new ValidationError('Use a valid ECR repository name.') } const response = await this.ecr.send(new CreateRepositoryCommand({repositoryName})) return toResource(response.repository ?? {repositoryName}) } async delete(id: string): Promise { await this.ecr.send(new DeleteRepositoryCommand({repositoryName: id, force: false})) } } class EcrImageStore implements ItemStoreAdapter { constructor(private readonly ecr: ECRClient) {} async listItems(resourceId: string, page: PageQuery = {}): Promise> { const response = await this.ecr.send(new DescribeImagesCommand({ repositoryName: resourceId, nextToken: page.cursor, maxResults: page.limit, })) return { items: (response.imageDetails ?? []).flatMap(toItem), nextCursor: response.nextToken ?? null, } } async deleteItem(resourceId: string, itemId: string): Promise { const response = await this.ecr.send(new BatchDeleteImageCommand({ repositoryName: resourceId, imageIds: [{imageDigest: itemId}], })) const failure = response.failures?.[0] if (failure) throw new RuntimeError(failure.failureReason ?? `Unable to delete image ${itemId}`) } } function toResource(repository: Repository): CloudResource { const name = repository.repositoryName ?? '' return { id: name, name, cloud: 'aws', service: 'ecr', type: 'repository', region: regionFromArn(repository.repositoryArn), createdAt: repository.createdAt?.toISOString() ?? null, metadata: { arn: repository.repositoryArn, registryId: repository.registryId, repositoryUri: repository.repositoryUri, imageTagMutability: repository.imageTagMutability, imageScanningConfiguration: repository.imageScanningConfiguration, encryptionConfiguration: repository.encryptionConfiguration, }, } } function toItem(image: ImageDetail): ChildItem[] { if (!image.imageDigest) return [] return [{ id: image.imageDigest, collectionId: null, timestamp: image.imagePushedAt?.toISOString() ?? null, body: { imageDigest: image.imageDigest, imageTags: image.imageTags ?? [], imageSizeInBytes: image.imageSizeInBytes ?? null, imagePushedAt: image.imagePushedAt?.toISOString() ?? null, imageManifestMediaType: image.imageManifestMediaType ?? null, artifactMediaType: image.artifactMediaType ?? null, }, metadata: { imageScanStatus: image.imageScanStatus, imageScanFindingsSummary: image.imageScanFindingsSummary, registryId: image.registryId, repositoryName: image.repositoryName, }, }] } function stringValue(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null } function isRepositoryNotFound(error: unknown): boolean { return typeof error === 'object' && error !== null && 'name' in error && (error as {name?: string}).name === 'RepositoryNotFoundException' }