diff --git a/README.md b/README.md index 5dab15c..53e93c3 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No | | Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No | | Networking | Route 53 / DNS | Yes (list, create, delete, inspect) | No | No | +| Networking | CloudFront / CDN | Yes (list, create, delete, inspect) | No | No | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | diff --git a/packages/api/package.json b/packages/api/package.json index 31fb8ee..2309697 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-cloudformation": "^3.1111.0", + "@aws-sdk/client-cloudfront": "^3.1146.0", "@aws-sdk/client-cloudwatch": "^3.1076.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0", "@aws-sdk/client-codebuild": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsCdnAdapter.test.ts b/packages/api/src/adapter-aws/AwsCdnAdapter.test.ts new file mode 100644 index 0000000..a219955 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCdnAdapter.test.ts @@ -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 + +function stub(handler: (command: object) => SendResult | Promise) { + 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, s3: (command: object) => SendResult | Promise = () => ({})) { + 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) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsCdnAdapter.ts b/packages/api/src/adapter-aws/AwsCdnAdapter.ts new file mode 100644 index 0000000..9577106 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCdnAdapter.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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): 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(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 ?? '' +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index e5d6801..eb2a0ae 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -32,6 +32,7 @@ import { ElastiCacheClient } from "@aws-sdk/client-elasticache"; import { LightsailClient } from "@aws-sdk/client-lightsail"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; import { Route53Client } from "@aws-sdk/client-route-53"; +import { CloudFrontClient } from "@aws-sdk/client-cloudfront"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -95,6 +96,7 @@ export type AwsClients = { lightsail: LightsailClient; cognito: CognitoIdentityProviderClient; route53: Route53Client; + cloudfront: CloudFrontClient; }; export type AwsClientName = keyof AwsClients; @@ -142,6 +144,7 @@ function buildClients(accountId: string): AwsClients { lightsail: new LightsailClient(base), cognito: new CognitoIdentityProviderClient(base), route53: new Route53Client(base), + cloudfront: new CloudFrontClient(base), }; } diff --git a/packages/api/src/cloud-spi/cdnSchema.ts b/packages/api/src/cloud-spi/cdnSchema.ts new file mode 100644 index 0000000..61e0e7d --- /dev/null +++ b/packages/api/src/cloud-spi/cdnSchema.ts @@ -0,0 +1,131 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +export const CDN_ORIGIN_TYPES = ['s3', 'custom'] as const +export const CDN_VIEWER_PROTOCOLS = ['redirect-to-https', 'allow-all', 'https-only'] as const +export const CDN_ORIGIN_PROTOCOLS = ['https-only', 'http-only', 'match-viewer'] as const +export const CDN_ALLOWED_METHODS = ['read', 'all'] as const +/** An S3 bucket name as S3 allows it for new buckets. */ +export const CDN_BUCKET_PATTERN = '^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$' +/** A host name, optionally with a port, as a custom origin's DomainName. */ +export const CDN_ORIGIN_DOMAIN_PATTERN = '^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$' + +const distributionColumns: TableColumnSchema[] = [ + {name: 'id', label: 'Distribution ID', format: 'code'}, + {name: 'viewerUrl', label: 'Local URL', path: 'metadata.viewerUrl', format: 'code'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'enabled', label: 'Enabled', path: 'metadata.enabled', format: 'boolean'}, + {name: 'origins', label: 'Origins', path: 'metadata.originDomains', format: 'list'}, + {name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'}, +] + +const distributionFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +export function awsCdnSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'cdn', + displayName: 'Amazon CloudFront distributions', + fields: [ + { + name: 'originType', + label: 'Origin', + type: 'select', + required: true, + defaultValue: 's3', + options: [{label: 'S3 bucket', value: 's3'}, {label: 'Custom origin (a web server or API)', value: 'custom'}], + }, + { + name: 'bucket', + label: 'S3 bucket', + type: 'text', + required: false, + requiredWhen: {field: 'originType', equals: 's3'}, + visibleWhen: {field: 'originType', values: ['s3']}, + validation: {pattern: CDN_BUCKET_PATTERN, message: 'Use the name of an existing bucket'}, + }, + { + name: 'originDomain', + label: 'Origin domain', + type: 'text', + required: false, + requiredWhen: {field: 'originType', equals: 'custom'}, + visibleWhen: {field: 'originType', values: ['custom']}, + description: 'For example api.example.com. A local server needs FLOCI_SERVICES_CLOUDFRONT_ALLOWED_PRIVATE_ORIGIN_HOSTS on the runtime.', + validation: {pattern: CDN_ORIGIN_DOMAIN_PATTERN, message: 'Use a host name such as api.example.com'}, + }, + { + name: 'originProtocol', + label: 'Origin protocol', + type: 'select', + required: false, + defaultValue: 'https-only', + visibleWhen: {field: 'originType', values: ['custom']}, + options: [ + {label: 'HTTPS only', value: 'https-only'}, + {label: 'HTTP only', value: 'http-only'}, + {label: 'Match the viewer', value: 'match-viewer'}, + ], + }, + { + name: 'originPort', + label: 'Origin port', + type: 'text', + required: false, + visibleWhen: {field: 'originType', values: ['custom']}, + description: 'Leave empty for 443 (HTTPS) and 80 (HTTP).', + validation: {pattern: '^\\d{1,5}$', message: 'Use a port number'}, + }, + { + name: 'defaultRootObject', + label: 'Default root object', + type: 'text', + required: false, + defaultValue: 'index.html', + description: 'What the distribution serves for a request to /.', + }, + { + name: 'viewerProtocol', + label: 'Viewer protocol', + type: 'select', + required: false, + defaultValue: 'allow-all', + options: [ + {label: 'HTTP and HTTPS', value: 'allow-all'}, + {label: 'Redirect HTTP to HTTPS', value: 'redirect-to-https'}, + {label: 'HTTPS only', value: 'https-only'}, + ], + }, + { + name: 'allowedMethods', + label: 'Allowed methods', + type: 'select', + required: false, + defaultValue: 'read', + options: [ + {label: 'GET and HEAD (a website or files)', value: 'read'}, + {label: 'All methods (an API)', value: 'all'}, + ], + }, + {name: 'comment', label: 'Description', type: 'text', required: false}, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List distributions', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create distribution', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Disable and delete distribution', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect distribution', enabled: true, status: 'available', runtimeRequired: true}, + ], + distributionActions: [ + {name: 'setEnabled', label: 'Enable or disable a distribution', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createInvalidation', label: 'Invalidate cached paths', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: distributionFilters, + columns: distributionColumns, + } +} + +export function cdnSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'aws' ? awsCdnSchema() : null +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index 37efed8..ced0375 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -239,6 +239,13 @@ export const SERVICE_CATALOG = { // AWS retains its dedicated page while Azure and GCP use Cloud Explorer. routeByCloud: {aws: '/secretsmanager'}, }, + cdn: { + displayName: 'CDN', + displayNameByCloud: {aws: 'CloudFront'}, + iconKey: 'cdn', + group: 'Networking', + order: 50, + }, dns: { displayName: 'DNS', displayNameByCloud: {aws: 'Route 53'}, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 248ff80..8929d35 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -180,6 +180,7 @@ export type UserPoolActionName = | 'testSignIn' | 'describeClient' | 'updateClientOAuth' +export type DistributionActionName = 'setEnabled' | 'createInvalidation' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export interface CapabilitySchema { @@ -224,6 +225,7 @@ export interface ServiceSchema { databaseActions?: CapabilitySchema[] kubernetesActions?: CapabilitySchema[] userPoolActions?: CapabilitySchema[] + distributionActions?: CapabilitySchema[] // Child levels advertise separately from the resource level because a // store can be readable at the leaf and writable at the collection — // CloudWatch Logs creates streams but cannot delete an event. @@ -455,6 +457,13 @@ export interface UserPoolSignInTestInput { } /** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */ +export interface DistributionInvalidation { + id: string + status: string + createdAt: string | null + paths: string[] +} + export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials' /** The app client settings that decide how the hosted login and OAuth endpoints treat it. */ @@ -996,6 +1005,8 @@ export interface CloudServiceAdapter { deleteUserPoolGroup?(poolId: string, groupName: string): Promise testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise describeUserPoolClient?(poolId: string, clientId: string): Promise + setDistributionEnabled?(distributionId: string, enabled: boolean): Promise + createDistributionInvalidation?(distributionId: string, paths: string[]): Promise updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise /** Clears the provider's locally captured email inbox, if it exposes one. */ clearEmailInbox?(): Promise diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 6eb2e96..33ec257 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {isServiceType} from './cloud-spi/serviceCatalog' -import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' /** * Guards the schema-to-implementation contract for every registered adapter. @@ -47,6 +47,11 @@ const METHOD_FOR_KUBERNETES_ACTION: Record = { + setEnabled: 'setDistributionEnabled', + createInvalidation: 'createDistributionInvalidation', +} + const METHOD_FOR_USER_POOL_ACTION: Record = { updateUser: 'updateUserPoolUser', setUserGroups: 'setUserPoolUserGroups', @@ -141,6 +146,15 @@ describe('registered adapters honour their schema', () => { `${label} advertises ${capability.name} as available but has no ${String(method)}()`, ).toBe('function') } + + const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') + for (const capability of distributionClaims) { + const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } }) test(`${label} child collections match their advertised capabilities`, () => { @@ -154,6 +168,7 @@ describe('registered adapters honour their schema', () => { ...(adapter.schema().capabilities?.databaseActions ?? []), ...(adapter.schema().capabilities?.kubernetesActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []), + ...(adapter.schema().capabilities?.distributionActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.itemActions ?? []), ] diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index ff5ec12..9d3dd87 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -49,6 +49,7 @@ import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter' import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' +import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -100,6 +101,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsLightsailAdapter(clients.lightsail), new AwsUserPoolsAdapter(clients.cognito), new AwsDnsAdapter(clients.route53), + new AwsCdnAdapter(clients.cloudfront, clients.s3), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index f0310ba..a167240 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -17,6 +17,7 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema' import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' +import {awsCdnSchema} from '../cloud-spi/cdnSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' @@ -769,6 +770,31 @@ describe('cloud schema routes', () => { expect(calls).toEqual([['us-east-1_x', 'c1', input]]) }) + test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => { + const calls: unknown[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'cdn', + schema: awsCdnSchema, + setDistributionEnabled: async (id, enabled) => { + calls.push(['enabled', id, enabled]) + return {id, name: 'd.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution', region: null, createdAt: null, metadata: {enabled}} + }, + createDistributionInvalidation: async (id, paths) => { + calls.push(['invalidate', id, paths]) + return {id: 'I1', status: 'Completed', createdAt: null, paths} + }, + })]) + const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)}) + const base = '/api/clouds/aws/services/cdn/resources/E123' + + const enabled = await app.request(`${base}/enabled`, json('PUT', {enabled: false})) + expect(enabled.status).toBe(200) + expect((await enabled.json()).metadata.enabled).toBe(false) + const invalidated = await app.request(`${base}/invalidations`, json('POST', {paths: ['/*']})) + expect(invalidated.status).toBe(201) + expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]]) + }) + test('clears the SES mailbox through the email adapter', async () => { let cleared = false const app = appWithRoutes([mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index a00d2f2..dd3e123 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -461,6 +461,26 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json()))) }) + app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{enabled?: boolean}>() + return c.json(await svc(c).setDistributionEnabled(cloud, c.req.param('id'), body.enabled as boolean)) + }) + }) + + app.post('/:cloud/services/cdn/resources/:id/invalidations', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{paths?: string[]}>() + return c.json(await svc(c).createDistributionInvalidation(cloud, c.req.param('id'), body.paths as string[]), 201) + }) + }) + app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index bb28558..58406af 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -82,6 +82,7 @@ import type { UserPoolSignInTestInput, UserPoolSignInTestResult, UserPoolClientSettings, + DistributionInvalidation, UpdateUserPoolClientOAuthInput, UserPoolUserAction, } from '../cloud-spi/types' @@ -833,6 +834,18 @@ export class CloudProxyService { return adapter.testUserPoolSignIn(poolId, input) } + async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise { + const adapter = this.requireAdapter(cloud, 'cdn') + if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`) + return adapter.setDistributionEnabled(distributionId, enabled) + } + + async createDistributionInvalidation(cloud: CloudProvider, distributionId: string, paths: string[]): Promise { + const adapter = this.requireAdapter(cloud, 'cdn') + if (!adapter.createDistributionInvalidation) throw new NotSupportedError(`Invalidations are not supported for ${cloud}/cdn`) + return adapter.createDistributionInvalidation(distributionId, paths) + } + async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise { const adapter = this.requireAdapter(cloud, 'userpools') if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 7ef7420..64df429 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -130,6 +130,10 @@ export const apiEndpointKeys = { messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"}, moveTasks: "clouds.services.messaging.move-tasks", }, + cdn: { + enabled: "clouds.services.cdn.enabled", + invalidations: "clouds.services.cdn.invalidations", + }, userpools: { userAction: "clouds.services.userpools.users.action", userGroups: "clouds.services.userpools.users.groups", @@ -886,6 +890,20 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "POST", }, ], + [ + apiEndpointKeys.clouds.cdn.enabled, + { + path: "/clouds/:cloud/services/cdn/resources/:id/enabled", + method: "PUT", + }, + ], + [ + apiEndpointKeys.clouds.cdn.invalidations, + { + path: "/clouds/:cloud/services/cdn/resources/:id/invalidations", + method: "POST", + }, + ], [ apiEndpointKeys.clouds.userpools.client.get, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 393526f..32468b1 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -32,6 +32,7 @@ import type { UserPoolSignInTestInput, UserPoolSignInTestResult, UserPoolClientSettings, + DistributionInvalidation, UpdateUserPoolClientOAuthInput, UserPoolUserAction, LogsInsightsQueryInput, @@ -1476,6 +1477,32 @@ export async function testUserPoolSignIn( return res.data; } +export async function setDistributionEnabled( + cloud: CloudProvider, + distributionId: string, + enabled: boolean, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.cdn.enabled, + requestOptions(cloud, "cdn", { body: { enabled } }), + { cloud, id: distributionId }, + ); + return res.data; +} + +export async function createDistributionInvalidation( + cloud: CloudProvider, + distributionId: string, + paths: string[], +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.cdn.invalidations, + requestOptions(cloud, "cdn", { body: { paths } }), + { cloud, id: distributionId }, + ); + return res.data; +} + export async function describeUserPoolClient( cloud: CloudProvider, poolId: string, diff --git a/packages/frontend/src/components/CloudFrontPanel.test.tsx b/packages/frontend/src/components/CloudFrontPanel.test.tsx new file mode 100644 index 0000000..221cf47 --- /dev/null +++ b/packages/frontend/src/components/CloudFrontPanel.test.tsx @@ -0,0 +1,71 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {render, screen, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {beforeEach, expect, test, vi} from 'vitest' +import {CloudFrontPanel} from './CloudFrontPanel' +import type {CloudResource} from '@/types/resource' + +const api = vi.hoisted(() => ({ + getCloudResource: vi.fn(), + setDistributionEnabled: vi.fn(), + createDistributionInvalidation: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) +vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'})) + +const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/' +const distribution: CloudResource = { + id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution', + region: null, createdAt: null, status: 'Deployed', + metadata: { + viewerUrl: VIEWER_URL, domainName: 'e7cpm0p0g0vc8d.cloudfront.net', enabled: true, defaultRootObject: 'index.html', + origins: [{id: 's3-site', domainName: 'site.s3.amazonaws.com', kind: 's3', protocol: null}], + behaviors: [{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'redirect-to-https', methods: ['GET', 'HEAD']}], + invalidations: [{id: 'I1', status: 'Completed', createdAt: null, paths: []}], + }, +} + +function renderPanel() { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render() +} + +beforeEach(() => { + vi.clearAllMocks() + api.getCloudResource.mockResolvedValue(distribution) + api.setDistributionEnabled.mockImplementation(async (_cloud, _id, enabled) => ({...distribution, metadata: {...distribution.metadata, enabled}})) + api.createDistributionInvalidation.mockImplementation(async (_cloud, _id, paths) => ({id: 'I2', status: 'Completed', createdAt: null, paths})) +}) + +test('opens the distribution locally and shows its origins and behaviours', async () => { + renderPanel() + + expect(await screen.findByRole('link', {name: /Open/})).toHaveAttribute('href', VIEWER_URL) + expect(screen.getByText('site.s3.amazonaws.com')).toBeInTheDocument() + expect(screen.getByText('Redirect to HTTPS')).toBeInTheDocument() + expect(screen.getByText('I1')).toBeInTheDocument() +}) + +test('disables the distribution', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(await screen.findByRole('button', {name: 'Disable'})) + expect(api.setDistributionEnabled).toHaveBeenCalledWith('aws', distribution.id, false) + expect(await screen.findByText(/Distribution disabled/)).toBeInTheDocument() +}) + +test('invalidates the paths entered, one per line', async () => { + const user = userEvent.setup() + renderPanel() + + const form = await screen.findByRole('form', {name: 'Create invalidation'}) + const field = within(form).getByLabelText(/Paths/) + await user.clear(field) + await user.type(field, '/index.html{enter}/assets/*') + await user.click(within(form).getByRole('button', {name: 'Create invalidation'})) + + expect(api.createDistributionInvalidation).toHaveBeenCalledWith('aws', distribution.id, ['/index.html', '/assets/*']) + expect(await screen.findByText(/Invalidation I2 completed for \/index.html, \/assets\/\*/)).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/CloudFrontPanel.tsx b/packages/frontend/src/components/CloudFrontPanel.tsx new file mode 100644 index 0000000..2550563 --- /dev/null +++ b/packages/frontend/src/components/CloudFrontPanel.tsx @@ -0,0 +1,124 @@ +import {ExternalLink, RefreshCw} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {createDistributionInvalidation, getCloudResource, setDistributionEnabled} from '@/api/cloudProxyClient' +import {CopyValueButton} from '@/components/CopyValueButton' +import {useAccountId} from '@/lib/accountStore' +import {formatDateTime} from '@/lib/format' +import type {CloudResource} from '@/types/resource' + +type Props = { + resource?: CloudResource + runtimeReachable: boolean +} + +type Origin = {id: string; domainName: string; kind: string; protocol: string | null} +type Behavior = {pathPattern: string; originId: string; viewerProtocol: string; methods: string[]} +type Invalidation = {id: string; status: string; createdAt: string | null} + +const VIEWER_PROTOCOL: Record = { + 'allow-all': 'HTTP and HTTPS', + 'redirect-to-https': 'Redirect to HTTPS', + 'https-only': 'HTTPS only', +} + +export function CloudFrontPanel({resource, runtimeReachable}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const [paths, setPaths] = useState('/*') + const [notice, setNotice] = useState('') + const distributionKey = ['cdn-distribution', accountId, resource?.id] + + // The table row is a list summary; behaviours and invalidations come from the full distribution. + const distributionQuery = useQuery({ + queryKey: distributionKey, + queryFn: ({signal}) => getCloudResource('aws', 'cdn', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setPaths('/*') + setNotice('') + }, [resource?.id]) + + const refresh = () => Promise.all([ + queryClient.invalidateQueries({queryKey: distributionKey}), + queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'cdn']}), + ]) + + const enabledMutation = useMutation({ + mutationFn: (enabled: boolean) => setDistributionEnabled('aws', resource?.id ?? '', enabled), + onSuccess: (updated) => { + setNotice(updated.metadata.enabled ? 'Distribution enabled.' : 'Distribution disabled. It no longer answers viewer requests.') + return refresh() + }, + }) + const invalidationMutation = useMutation({ + mutationFn: () => createDistributionInvalidation('aws', resource?.id ?? '', paths.split('\n')), + onSuccess: (invalidation) => { + setNotice(`Invalidation ${invalidation.id} ${invalidation.status === 'Completed' ? 'completed' : 'started'} for ${invalidation.paths.join(', ')}.`) + return refresh() + }, + }) + + if (!resource) { + return

Select a distribution

Choose a distribution to open it, change its state or invalidate cached files.

+ } + + const distribution = distributionQuery.data ?? resource + const metadata = distribution.metadata + const viewerUrl = typeof metadata.viewerUrl === 'string' ? metadata.viewerUrl : '' + const enabled = metadata.enabled === true + const origins = list(metadata.origins) + const behaviors = list(metadata.behaviors) + const invalidations = list(metadata.invalidations) + const pending = enabledMutation.isPending || invalidationMutation.isPending + const disabled = !runtimeReachable || pending + const error = distributionQuery.error ?? enabledMutation.error ?? invalidationMutation.error + + return
+
+

Distribution

{enabled ? 'Enabled' : 'Disabled'} · {distribution.status ?? 'Unknown'}{metadata.defaultRootObject ? ` · serves ${String(metadata.defaultRootObject)} at /` : ''}
+
+ + +
+
+ {viewerUrl &&
+ {viewerUrl} + Open + Served by TheCloud on this machine. On AWS the same distribution answers at {String(metadata.domainName ?? distribution.name)}. +
} + +

Origins

+ {origins.length > 0 &&
{origins.map((origin) => ( + + ))}
OriginDomainType
{origin.id}{origin.domainName}{origin.kind === 's3' ? 'S3 bucket' : `Custom (${origin.protocol ?? 'https-only'})`}
} + + {behaviors.length > 0 && <> +

Cache behaviours

The first path pattern that matches a request decides how it is served.
+
{behaviors.map((behavior) => ( + + ))}
Path patternOriginViewer protocolMethods
{behavior.pathPattern}{behavior.originId}{VIEWER_PROTOCOL[behavior.viewerProtocol] ?? behavior.viewerProtocol}{behavior.methods.join(', ')}
+ } + +

Invalidations

Clear cached copies so the next request fetches them from the origin.
+
{ event.preventDefault(); setNotice(''); invalidationMutation.mutate() }}> +