Files
floci-ui/packages/api/src/adapter-aws/AwsEcrAdapter.ts
T
2026-09-26 16:14:24 -04:00

156 lines
5.7 KiB
TypeScript

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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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<CollectionPage<ChildItem>> {
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<void> {
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'
}