156 lines
5.7 KiB
TypeScript
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'
|
|
}
|