feat(api): add CloudFront distributions to the cloud explorer
CI / checks (pull_request) Successful in 26m22s
CI / checks (pull_request) Successful in 26m22s
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateDistributionCommand,
|
||||
CreateInvalidationCommand,
|
||||
DeleteDistributionCommand,
|
||||
GetDistributionCommand,
|
||||
GetDistributionConfigCommand,
|
||||
ListDistributionsCommand,
|
||||
ListInvalidationsCommand,
|
||||
UpdateDistributionCommand,
|
||||
type CloudFrontClient,
|
||||
type DistributionConfig,
|
||||
} from '@aws-sdk/client-cloudfront'
|
||||
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
||||
import {AwsCdnAdapter} from './AwsCdnAdapter'
|
||||
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
}
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
function awsError(name: string, status = 400): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||
}
|
||||
|
||||
const ID = 'E7CPM0P0G0VC8D'
|
||||
const origin = {Id: 's3-site', DomainName: 'site.s3.amazonaws.com', S3OriginConfig: {OriginAccessIdentity: ''}}
|
||||
const config = {
|
||||
CallerReference: 'r', Comment: 'Docs site', Enabled: true, DefaultRootObject: 'index.html',
|
||||
Origins: {Quantity: 1, Items: [origin]},
|
||||
DefaultCacheBehavior: {TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', AllowedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
||||
CacheBehaviors: {Quantity: 1, Items: [{PathPattern: '/api/*', TargetOriginId: 'custom-api', ViewerProtocolPolicy: 'https-only', AllowedMethods: {Quantity: 7, Items: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']}}]},
|
||||
} as DistributionConfig
|
||||
const distribution = {Id: ID, ARN: `arn:aws:cloudfront::000000000000:distribution/${ID}`, DomainName: 'e7cpm0p0g0vc8d.cloudfront.net', Status: 'Deployed', LastModifiedTime: new Date('2026-10-24T18:00:00Z'), DistributionConfig: config}
|
||||
|
||||
function adapter(cloudfront: (command: object) => SendResult | Promise<SendResult>, s3: (command: object) => SendResult | Promise<SendResult> = () => ({})) {
|
||||
const cf = stub(cloudfront)
|
||||
const bucket = stub(s3)
|
||||
return {adapter: new AwsCdnAdapter(cf.client as unknown as CloudFrontClient, bucket.client as unknown as S3Client, 'http://floci:4566'), sent: cf.sent, s3Sent: bucket.sent}
|
||||
}
|
||||
|
||||
describe('AwsCdnAdapter distributions', () => {
|
||||
test('lists distributions with their local URL, origins and description, and filters them', async () => {
|
||||
const {adapter: cdn} = adapter((command) => command instanceof ListDistributionsCommand
|
||||
? {DistributionList: {Items: [{...distribution, Enabled: true, Comment: 'Docs site', Origins: config.Origins}], IsTruncated: false}}
|
||||
: {})
|
||||
|
||||
const [listed] = await cdn.list()
|
||||
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
|
||||
expect(listed.metadata).toMatchObject({viewerUrl: 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/', enabled: true, comment: 'Docs site', originDomains: ['site.s3.amazonaws.com']})
|
||||
expect(await cdn.list({search: 'site.s3'})).toHaveLength(1)
|
||||
expect(await cdn.list({search: 'nothing'})).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('describes behaviours and recent invalidations, and treats a missing distribution as absent', async () => {
|
||||
const {adapter: cdn} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand && command.input.Id === ID) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionCommand) throw awsError('NoSuchDistribution', 404)
|
||||
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: [{Id: 'I1', Status: 'Completed', CreateTime: new Date('2026-10-24T18:05:00Z')}]}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const described = await cdn.get(ID)
|
||||
expect(described?.metadata).toMatchObject({
|
||||
defaultRootObject: 'index.html',
|
||||
behaviors: [
|
||||
{pathPattern: '/api/*', originId: 'custom-api', viewerProtocol: 'https-only', methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']},
|
||||
{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'allow-all', methods: ['GET', 'HEAD']},
|
||||
],
|
||||
invalidations: [{id: 'I1', status: 'Completed', createdAt: '2026-10-24T18:05:00.000Z', paths: []}],
|
||||
})
|
||||
expect(await cdn.get('EMISSING')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a cached website from a bucket that exists', async () => {
|
||||
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
||||
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
||||
: {})
|
||||
|
||||
await cdn.create({values: {originType: 's3', bucket: 'site', defaultRootObject: '/index.html', comment: 'Docs'}})
|
||||
|
||||
expect(s3Sent[0]).toBeInstanceOf(HeadBucketCommand)
|
||||
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
||||
expect(created).toMatchObject({Enabled: true, Comment: 'Docs', DefaultRootObject: 'index.html', Origins: {Quantity: 1, Items: [{Id: 's3-site', DomainName: 'site.s3.amazonaws.com'}]}})
|
||||
expect(created.DefaultCacheBehavior).toMatchObject({TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', AllowedMethods: {Quantity: 2}})
|
||||
expect(created.DefaultCacheBehavior?.OriginRequestPolicyId).toBeUndefined()
|
||||
})
|
||||
|
||||
test('creates an uncached API in front of a custom origin, forwarding the viewer request', async () => {
|
||||
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
||||
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
||||
: {})
|
||||
|
||||
await cdn.create({values: {originType: 'custom', originDomain: 'API.example.com', originProtocol: 'http-only', originPort: '8080', allowedMethods: 'all', viewerProtocol: 'redirect-to-https'}})
|
||||
|
||||
expect(s3Sent).toHaveLength(0)
|
||||
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
||||
expect(created.Origins?.Items?.[0]).toMatchObject({Id: 'custom-api.example.com', DomainName: 'api.example.com', CustomOriginConfig: {HTTPPort: 8080, HTTPSPort: 8080, OriginProtocolPolicy: 'http-only'}})
|
||||
expect(created.DefaultCacheBehavior).toMatchObject({
|
||||
ViewerProtocolPolicy: 'redirect-to-https', AllowedMethods: {Quantity: 7},
|
||||
CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', OriginRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac',
|
||||
})
|
||||
})
|
||||
|
||||
test('refuses a missing bucket and invalid settings before creating anything', async () => {
|
||||
const {adapter: cdn, sent} = adapter(() => ({}), () => { throw awsError('NotFound', 404) })
|
||||
|
||||
await expect(cdn.create({values: {originType: 's3', bucket: 'nope'}})).rejects.toThrow(/no S3 bucket named nope/)
|
||||
await expect(cdn.create({values: {originType: 's3', bucket: 'Bad_Name'}})).rejects.toThrow(ValidationError)
|
||||
await expect(cdn.create({values: {originType: 'custom', originDomain: 'not a host'}})).rejects.toThrow(ValidationError)
|
||||
await expect(cdn.create({values: {originType: 'custom', originDomain: 'api.example.com', originPort: '70000'}})).rejects.toThrow(/originPort/)
|
||||
await expect(cdn.create({values: {originType: 'ftp'}})).rejects.toThrow(/originType/)
|
||||
expect(sent.some((command) => command instanceof CreateDistributionCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('disables an enabled distribution, then deletes it with the new ETag', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'Deployed'}, ETag: 'e2'}
|
||||
return {}
|
||||
})
|
||||
|
||||
await cdn.delete(ID)
|
||||
|
||||
const update = sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand
|
||||
expect(update.input).toMatchObject({Id: ID, IfMatch: 'e1', DistributionConfig: {Enabled: false, Comment: 'Docs site'}})
|
||||
expect((sent.find((command) => command instanceof DeleteDistributionCommand) as DeleteDistributionCommand).input).toEqual({Id: ID, IfMatch: 'e2'})
|
||||
})
|
||||
|
||||
test('says to try again while CloudFront is still deploying the disable', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'InProgress'}, ETag: 'e2'}
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(cdn.delete(ID)).rejects.toThrow(ConflictError)
|
||||
expect(sent.some((command) => command instanceof DeleteDistributionCommand)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsCdnAdapter actions', () => {
|
||||
test('enables or disables a distribution, keeping the rest of its configuration', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: {...distribution, DistributionConfig: {...config, Enabled: false}}, ETag: 'e2'}
|
||||
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: []}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const disabled = await cdn.setDistributionEnabled(ID, false)
|
||||
expect(disabled.metadata.enabled).toBe(false)
|
||||
expect((sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand).input.DistributionConfig).toEqual({...config, Enabled: false})
|
||||
|
||||
// The stored configuration is already enabled: nothing to change.
|
||||
const before = sent.length
|
||||
await cdn.setDistributionEnabled(ID, true)
|
||||
expect(sent.slice(before).some((command) => command instanceof UpdateDistributionCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('invalidates paths that start with a slash', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => command instanceof CreateInvalidationCommand
|
||||
? {Invalidation: {Id: 'I2', Status: 'Completed', CreateTime: new Date('2026-10-24T18:10:00Z'), InvalidationBatch: command.input.InvalidationBatch}}
|
||||
: {})
|
||||
|
||||
expect(await cdn.createDistributionInvalidation(ID, [' /* ', '/index.html', '/*'])).toEqual({id: 'I2', status: 'Completed', createdAt: '2026-10-24T18:10:00.000Z', paths: ['/*', '/index.html']})
|
||||
await expect(cdn.createDistributionInvalidation(ID, ['index.html'])).rejects.toThrow(/must start with \//)
|
||||
await expect(cdn.createDistributionInvalidation(ID, [])).rejects.toThrow(/at least one path/)
|
||||
expect(sent.filter((command) => command instanceof CreateInvalidationCommand)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
CreateDistributionCommand,
|
||||
CreateInvalidationCommand,
|
||||
DeleteDistributionCommand,
|
||||
GetDistributionCommand,
|
||||
GetDistributionConfigCommand,
|
||||
ListDistributionsCommand,
|
||||
ListInvalidationsCommand,
|
||||
UpdateDistributionCommand,
|
||||
type CacheBehavior,
|
||||
type CloudFrontClient,
|
||||
type DefaultCacheBehavior,
|
||||
type Distribution,
|
||||
type DistributionConfig,
|
||||
type DistributionSummary,
|
||||
type Origin,
|
||||
} from '@aws-sdk/client-cloudfront'
|
||||
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
||||
import {
|
||||
CDN_ALLOWED_METHODS,
|
||||
CDN_BUCKET_PATTERN,
|
||||
CDN_ORIGIN_DOMAIN_PATTERN,
|
||||
CDN_ORIGIN_PROTOCOLS,
|
||||
CDN_ORIGIN_TYPES,
|
||||
CDN_VIEWER_PROTOCOLS,
|
||||
awsCdnSchema,
|
||||
} from '../cloud-spi/cdnSchema'
|
||||
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, DistributionInvalidation, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** AWS managed cache and origin request policies, which CloudFront and the runtime both know by ID. */
|
||||
const CACHING_OPTIMIZED = '658327ea-f89d-4fab-a63d-7e88639e58f6'
|
||||
const CACHING_DISABLED = '4135ea2d-6df8-44a3-9df3-4b5a84be39ad'
|
||||
const ALL_VIEWER_EXCEPT_HOST_HEADER = 'b689b0a8-53d0-40ab-baf2-68738e2966ac'
|
||||
const READ_METHODS = ['GET', 'HEAD']
|
||||
const ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']
|
||||
/** CloudFront's limit on paths in one invalidation that are not wildcards. */
|
||||
const MAX_INVALIDATION_PATHS = 3000
|
||||
const RECENT_INVALIDATIONS = 5
|
||||
|
||||
/**
|
||||
* Talks to CloudFront distributions, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - A distribution is `Deployed` as soon as it is created or updated; AWS takes minutes.
|
||||
* - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled`
|
||||
* (409), as on AWS, so delete disables it first.
|
||||
* - A missing distribution is `NoSuchDistribution` with HTTP 404.
|
||||
* - Viewer requests reach a distribution at `{id}.cloudfront.localhost.floci.io` on the
|
||||
* runtime's port, a name that resolves to 127.0.0.1 without any hosts-file entry.
|
||||
*/
|
||||
export class AwsCdnAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'cdn' as const
|
||||
|
||||
constructor(
|
||||
private readonly cloudfront: CloudFrontClient,
|
||||
private readonly s3: S3Client,
|
||||
private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566',
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsCdnSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const summaries: DistributionSummary[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const res = await this.cloudfront.send(new ListDistributionsCommand({...(marker ? {Marker: marker} : {})}))
|
||||
summaries.push(...(res.DistributionList?.Items ?? []))
|
||||
marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined
|
||||
} while (marker)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return summaries.map((summary) => this.toResource(summary, summary.Enabled ?? false, summary.Comment, summary.Origins?.Items))
|
||||
.filter((resource) => !search || [resource.id, resource.name, ...stringList(resource.metadata.originDomains)].some((value) => value.toLowerCase().includes(search)))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
let distribution: Distribution | undefined
|
||||
try {
|
||||
distribution = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).Distribution
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NoSuchDistribution') return null
|
||||
throw error
|
||||
}
|
||||
if (!distribution) return null
|
||||
const config = distribution.DistributionConfig
|
||||
const invalidations = await this.recentInvalidations(id)
|
||||
const resource = this.toResource(distribution, config?.Enabled ?? false, config?.Comment, config?.Origins?.Items)
|
||||
resource.metadata = {
|
||||
...resource.metadata,
|
||||
defaultRootObject: config?.DefaultRootObject || null,
|
||||
aliases: config?.Aliases?.Items ?? [],
|
||||
behaviors: behaviorsOf(config),
|
||||
invalidations,
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const originType = oneOf(values.originType ?? 's3', CDN_ORIGIN_TYPES, 'originType')
|
||||
const viewerProtocol = oneOf(values.viewerProtocol || 'allow-all', CDN_VIEWER_PROTOCOLS, 'viewerProtocol')
|
||||
const methods = oneOf(values.allowedMethods || 'read', CDN_ALLOWED_METHODS, 'allowedMethods')
|
||||
const comment = text(values.comment)
|
||||
if (comment.length > 128) throw new ValidationError('comment must be at most 128 characters')
|
||||
const defaultRootObject = text(values.defaultRootObject).replace(/^\/+/, '')
|
||||
|
||||
const origin = originType === 's3' ? await this.s3Origin(text(values.bucket)) : customOrigin(values)
|
||||
const allowed = methods === 'all' ? ALL_METHODS : READ_METHODS
|
||||
const behavior: DefaultCacheBehavior = {
|
||||
TargetOriginId: origin.Id,
|
||||
ViewerProtocolPolicy: viewerProtocol,
|
||||
AllowedMethods: {Quantity: allowed.length, Items: allowed as never, CachedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
||||
Compress: true,
|
||||
// A website is cached; an API, which sees every method, is not, and gets the viewer's request.
|
||||
CachePolicyId: methods === 'all' ? CACHING_DISABLED : CACHING_OPTIMIZED,
|
||||
...(methods === 'all' && originType === 'custom' ? {OriginRequestPolicyId: ALL_VIEWER_EXCEPT_HOST_HEADER} : {}),
|
||||
}
|
||||
const res = await this.cloudfront.send(new CreateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
CallerReference: crypto.randomUUID(),
|
||||
Comment: comment,
|
||||
Enabled: true,
|
||||
DefaultRootObject: defaultRootObject || undefined,
|
||||
Origins: {Quantity: 1, Items: [origin]},
|
||||
DefaultCacheBehavior: behavior,
|
||||
},
|
||||
}))
|
||||
if (!res.Distribution) throw new ValidationError('CloudFront did not return the new distribution')
|
||||
const config = res.Distribution.DistributionConfig
|
||||
return this.toResource(res.Distribution, config?.Enabled ?? true, config?.Comment, config?.Origins?.Items)
|
||||
}
|
||||
|
||||
/** CloudFront deletes only a disabled distribution, so an enabled one is disabled first. */
|
||||
async delete(id: string): Promise<void> {
|
||||
let etag = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).ETag
|
||||
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
|
||||
if (current.DistributionConfig?.Enabled) {
|
||||
const disabled = await this.cloudfront.send(new UpdateDistributionCommand({
|
||||
Id: id,
|
||||
IfMatch: current.ETag,
|
||||
DistributionConfig: {...current.DistributionConfig, Enabled: false},
|
||||
}))
|
||||
if (disabled.Distribution?.Status !== 'Deployed') {
|
||||
throw new ConflictError('The distribution is now disabled. CloudFront deletes it once the change is deployed: try again when its status is Deployed')
|
||||
}
|
||||
etag = disabled.ETag
|
||||
}
|
||||
try {
|
||||
await this.cloudfront.send(new DeleteDistributionCommand({Id: id, IfMatch: etag}))
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'DistributionNotDisabled') {
|
||||
throw new ConflictError('CloudFront is still disabling the distribution: try again when its status is Deployed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setDistributionEnabled(id: string, enabled: boolean): Promise<CloudResource> {
|
||||
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
|
||||
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
|
||||
if (!current.DistributionConfig) throw new NotFoundError(`Distribution ${id} was not found`)
|
||||
if (current.DistributionConfig.Enabled !== enabled) {
|
||||
await this.cloudfront.send(new UpdateDistributionCommand({
|
||||
Id: id,
|
||||
IfMatch: current.ETag,
|
||||
DistributionConfig: {...current.DistributionConfig, Enabled: enabled},
|
||||
}))
|
||||
}
|
||||
const resource = await this.get(id)
|
||||
if (!resource) throw new NotFoundError(`Distribution ${id} was not found`)
|
||||
return resource
|
||||
}
|
||||
|
||||
async createDistributionInvalidation(id: string, paths: string[]): Promise<DistributionInvalidation> {
|
||||
if (!Array.isArray(paths)) throw new ValidationError('paths must be a list')
|
||||
const items = [...new Set(paths.map((path) => String(path).trim()).filter(Boolean))]
|
||||
if (items.length === 0) throw new ValidationError('Add at least one path, such as /* or /index.html')
|
||||
if (items.length > MAX_INVALIDATION_PATHS) throw new ValidationError(`An invalidation holds at most ${MAX_INVALIDATION_PATHS} paths`)
|
||||
const invalid = items.find((path) => !path.startsWith('/'))
|
||||
if (invalid) throw new ValidationError(`${invalid} must start with /`)
|
||||
const res = await this.cloudfront.send(new CreateInvalidationCommand({
|
||||
DistributionId: id,
|
||||
InvalidationBatch: {CallerReference: crypto.randomUUID(), Paths: {Quantity: items.length, Items: items}},
|
||||
}))
|
||||
return {
|
||||
id: res.Invalidation?.Id ?? '',
|
||||
status: res.Invalidation?.Status ?? 'InProgress',
|
||||
createdAt: res.Invalidation?.CreateTime?.toISOString() ?? null,
|
||||
paths: res.Invalidation?.InvalidationBatch?.Paths?.Items ?? items,
|
||||
}
|
||||
}
|
||||
|
||||
private async s3Origin(bucket: string): Promise<Origin> {
|
||||
if (!new RegExp(CDN_BUCKET_PATTERN).test(bucket)) throw new ValidationError('bucket must be the name of an existing S3 bucket')
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({Bucket: bucket}))
|
||||
} catch (error) {
|
||||
if (['NotFound', 'NoSuchBucket'].includes(errorName(error))) throw new ValidationError(`There is no S3 bucket named ${bucket}`)
|
||||
throw error
|
||||
}
|
||||
return {Id: `s3-${bucket}`, DomainName: `${bucket}.s3.amazonaws.com`, S3OriginConfig: {OriginAccessIdentity: ''}}
|
||||
}
|
||||
|
||||
private async recentInvalidations(id: string): Promise<DistributionInvalidation[]> {
|
||||
const res = await this.cloudfront.send(new ListInvalidationsCommand({DistributionId: id, MaxItems: RECENT_INVALIDATIONS}))
|
||||
return (res.InvalidationList?.Items ?? []).map((invalidation) => ({
|
||||
id: invalidation.Id ?? '',
|
||||
status: invalidation.Status ?? '',
|
||||
createdAt: invalidation.CreateTime?.toISOString() ?? null,
|
||||
paths: [],
|
||||
}))
|
||||
}
|
||||
|
||||
private toResource(distribution: Pick<Distribution, 'Id' | 'DomainName' | 'Status' | 'LastModifiedTime' | 'ARN'>, enabled: boolean, comment: string | undefined, origins: Origin[] | undefined): CloudResource {
|
||||
const id = distribution.Id ?? ''
|
||||
return {
|
||||
id,
|
||||
name: distribution.DomainName ?? id,
|
||||
cloud: 'aws',
|
||||
service: 'cdn',
|
||||
type: 'distribution',
|
||||
region: null,
|
||||
createdAt: distribution.LastModifiedTime?.toISOString() ?? null,
|
||||
status: distribution.Status ?? null,
|
||||
metadata: {
|
||||
arn: distribution.ARN ?? null,
|
||||
domainName: distribution.DomainName ?? null,
|
||||
viewerUrl: this.viewerUrl(id),
|
||||
enabled,
|
||||
comment: comment || null,
|
||||
originDomains: (origins ?? []).map((origin) => origin.DomainName ?? ''),
|
||||
origins: (origins ?? []).map((origin) => ({
|
||||
id: origin.Id ?? '',
|
||||
domainName: origin.DomainName ?? '',
|
||||
kind: origin.S3OriginConfig ? 's3' : 'custom',
|
||||
protocol: origin.CustomOriginConfig?.OriginProtocolPolicy ?? null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a browser on this machine reaches the distribution through the runtime. */
|
||||
private viewerUrl(id: string): string {
|
||||
const port = new URL(this.endpoint).port || '4566'
|
||||
return `http://${id.toLowerCase()}.cloudfront.localhost.floci.io:${port}/`
|
||||
}
|
||||
}
|
||||
|
||||
function customOrigin(values: Record<string, unknown>): Origin {
|
||||
const domain = text(values.originDomain).toLowerCase()
|
||||
if (!new RegExp(CDN_ORIGIN_DOMAIN_PATTERN).test(domain)) throw new ValidationError('originDomain must be a host name such as api.example.com')
|
||||
const protocol = oneOf(values.originProtocol || 'https-only', CDN_ORIGIN_PROTOCOLS, 'originProtocol')
|
||||
const port = text(values.originPort)
|
||||
const portNumber = port ? Number(port) : null
|
||||
if (portNumber !== null && (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535)) throw new ValidationError('originPort must be between 1 and 65535')
|
||||
return {
|
||||
Id: `custom-${domain}`,
|
||||
DomainName: domain,
|
||||
CustomOriginConfig: {
|
||||
HTTPPort: portNumber ?? 80,
|
||||
HTTPSPort: portNumber ?? 443,
|
||||
OriginProtocolPolicy: protocol,
|
||||
OriginSslProtocols: {Quantity: 1, Items: ['TLSv1.2']},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function behaviorsOf(config: DistributionConfig | undefined) {
|
||||
const describe = (behavior: DefaultCacheBehavior | CacheBehavior, pathPattern: string) => ({
|
||||
pathPattern,
|
||||
originId: behavior.TargetOriginId ?? '',
|
||||
viewerProtocol: behavior.ViewerProtocolPolicy ?? '',
|
||||
methods: behavior.AllowedMethods?.Items ?? READ_METHODS,
|
||||
})
|
||||
return [
|
||||
...(config?.CacheBehaviors?.Items ?? []).map((behavior) => describe(behavior, behavior.PathPattern ?? '')),
|
||||
...(config?.DefaultCacheBehavior ? [describe(config.DefaultCacheBehavior, 'Default (*)')] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
|
||||
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
|
||||
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return (error as {name?: string} | null)?.name ?? ''
|
||||
}
|
||||
Reference in New Issue
Block a user