Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a86bee443b | |||
| 9cb164f211 | |||
| 6586f48539 | |||
| 5c3c73c582 | |||
| 2b4d4b146d | |||
| ac58bfc98b | |||
| 51a0a60a73 | |||
| a5c9de25d0 |
@@ -78,6 +78,8 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Networking | Networking | Yes (list) | No | No |
|
| Networking | Networking | Yes (list) | No | No |
|
||||||
| Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No |
|
| 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 | 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 | 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 | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||||
"@aws-sdk/client-cloudformation": "^3.1111.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": "^3.1076.0",
|
||||||
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
|
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
|
||||||
"@aws-sdk/client-codebuild": "^3.1076.0",
|
"@aws-sdk/client-codebuild": "^3.1076.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||||
"@aws-sdk/client-rds": "^3.1076.0",
|
"@aws-sdk/client-rds": "^3.1076.0",
|
||||||
"@aws-sdk/client-rds-data": "^3.1076.0",
|
"@aws-sdk/client-rds-data": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-route-53": "^3.1146.0",
|
||||||
"@aws-sdk/client-s3": "^3.1076.0",
|
"@aws-sdk/client-s3": "^3.1076.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.1076.0",
|
"@aws-sdk/client-secrets-manager": "^3.1076.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.1076.0",
|
"@aws-sdk/client-sesv2": "^3.1076.0",
|
||||||
|
|||||||
@@ -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: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,301 @@
|
|||||||
|
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` on the runtime's
|
||||||
|
* port. Browsers and curl resolve every `.localhost` name to the machine itself (RFC 6761),
|
||||||
|
* so no DNS or hosts-file entry is needed.
|
||||||
|
*/
|
||||||
|
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:${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 ?? ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
ChangeResourceRecordSetsCommand,
|
||||||
|
CreateHostedZoneCommand,
|
||||||
|
DeleteHostedZoneCommand,
|
||||||
|
GetHostedZoneCommand,
|
||||||
|
ListHostedZonesCommand,
|
||||||
|
ListResourceRecordSetsCommand,
|
||||||
|
type ResourceRecordSet,
|
||||||
|
type Route53Client,
|
||||||
|
} from '@aws-sdk/client-route-53'
|
||||||
|
import {AwsDnsAdapter} from './AwsDnsAdapter'
|
||||||
|
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stubRoute53(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
} as unknown as Route53Client
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
function awsError(name: string, status = 400): Error {
|
||||||
|
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZONE_ID = 'ZG9PAE0M9KRWJ4F'
|
||||||
|
const zone = {Id: `/hostedzone/${ZONE_ID}`, Name: 'example.test.', CallerReference: 'r', ResourceRecordSetCount: 3, Config: {Comment: 'Local zone', PrivateZone: false}}
|
||||||
|
const apex = [
|
||||||
|
{Name: 'example.test.', Type: 'NS', TTL: 172800, ResourceRecords: [{Value: 'ns-1.awsdns-01.org.'}]},
|
||||||
|
{Name: 'example.test.', Type: 'SOA', TTL: 900, ResourceRecords: [{Value: 'ns-1.awsdns-01.org awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400'}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
function zoneStub(records: object[] = apex, more: (command: object) => SendResult | undefined = () => undefined) {
|
||||||
|
return stubRoute53((command) => {
|
||||||
|
const extra = more(command)
|
||||||
|
if (extra) return extra
|
||||||
|
if (command instanceof GetHostedZoneCommand) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
|
||||||
|
if (command instanceof ListResourceRecordSetsCommand) return {ResourceRecordSets: records, IsTruncated: false}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsDnsAdapter hosted zones', () => {
|
||||||
|
test('lists zones by bare ID and filters by name', async () => {
|
||||||
|
const {client} = stubRoute53((command) => {
|
||||||
|
if (command instanceof ListHostedZonesCommand) {
|
||||||
|
return {HostedZones: [zone, {...zone, Id: '/hostedzone/ZPRIVATE', Name: 'internal.test.', Config: {PrivateZone: true}}], IsTruncated: false}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
const adapter = new AwsDnsAdapter(client)
|
||||||
|
|
||||||
|
const zones = await adapter.list()
|
||||||
|
expect(zones.map((resource) => [resource.id, resource.name, resource.metadata.visibility])).toEqual([[ZONE_ID, 'example.test', 'public'], ['ZPRIVATE', 'internal.test', 'private']])
|
||||||
|
expect((await adapter.list({search: 'INTERNAL'})).map((resource) => resource.id)).toEqual(['ZPRIVATE'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes a zone with its name servers, and treats a missing zone as absent', async () => {
|
||||||
|
const {client} = stubRoute53((command) => {
|
||||||
|
if (command instanceof GetHostedZoneCommand && command.input.Id === ZONE_ID) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}}
|
||||||
|
throw awsError('NoSuchHostedZone', 404)
|
||||||
|
})
|
||||||
|
const adapter = new AwsDnsAdapter(client)
|
||||||
|
|
||||||
|
expect((await adapter.get(ZONE_ID))?.metadata).toEqual({visibility: 'public', recordCount: 3, comment: 'Local zone', nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']})
|
||||||
|
expect(await adapter.get('ZMISSING')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a public zone, or a private one in a VPC', async () => {
|
||||||
|
const {client, sent} = stubRoute53((command) => command instanceof CreateHostedZoneCommand
|
||||||
|
? {HostedZone: {...zone, Name: `${command.input.Name}.`, Config: command.input.HostedZoneConfig}, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
|
||||||
|
: {})
|
||||||
|
const adapter = new AwsDnsAdapter(client)
|
||||||
|
|
||||||
|
expect((await adapter.create({values: {name: ' Example.TEST ', comment: 'Local zone'}})).name).toBe('example.test')
|
||||||
|
await adapter.create({values: {name: 'internal.test', vpcId: 'vpc-0abc1234'}})
|
||||||
|
|
||||||
|
const [publicZone, privateZone] = sent.filter((command) => command instanceof CreateHostedZoneCommand) as CreateHostedZoneCommand[]
|
||||||
|
expect(publicZone.input).toMatchObject({Name: 'example.test', HostedZoneConfig: {Comment: 'Local zone', PrivateZone: false}})
|
||||||
|
expect(publicZone.input.VPC).toBeUndefined()
|
||||||
|
expect(privateZone.input).toMatchObject({HostedZoneConfig: {PrivateZone: true}, VPC: {VPCId: 'vpc-0abc1234', VPCRegion: 'us-east-1'}})
|
||||||
|
await expect(adapter.create({values: {name: 'not a domain'}})).rejects.toThrow(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'example.test', vpcId: 'subnet-1'}})).rejects.toThrow(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explains that a zone with records cannot be deleted', async () => {
|
||||||
|
const {client} = stubRoute53((command) => {
|
||||||
|
if (command instanceof DeleteHostedZoneCommand) throw awsError('HostedZoneNotEmpty')
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
await expect(new AwsDnsAdapter(client).delete(ZONE_ID)).rejects.toThrow(ConflictError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AwsDnsAdapter records', () => {
|
||||||
|
test('lists records keyed by name and type, marking the zone\'s own NS and SOA', async () => {
|
||||||
|
const {client} = zoneStub([
|
||||||
|
...apex,
|
||||||
|
{Name: '\\052.example.test.', Type: 'A', TTL: 60, ResourceRecords: [{Value: '10.0.0.9'}]},
|
||||||
|
{Name: 'cdn.example.test.', Type: 'A', AliasTarget: {DNSName: 'd111.cloudfront.net.', HostedZoneId: 'Z2FDTNDATAQYW2', EvaluateTargetHealth: false}},
|
||||||
|
{Name: 'api.example.test.', Type: 'A', SetIdentifier: 'blue', Weight: 10, TTL: 60, ResourceRecords: [{Value: '10.0.0.2'}]},
|
||||||
|
])
|
||||||
|
const {items} = await new AwsDnsAdapter(client).items.listItems(ZONE_ID)
|
||||||
|
|
||||||
|
expect(items.map((item) => item.id)).toEqual(['example.test|NS', 'example.test|SOA', '*.example.test|A', 'cdn.example.test|A', 'api.example.test|A|blue'])
|
||||||
|
expect(items[0].body).toMatchObject({managed: true, editable: true})
|
||||||
|
expect(items[2].body).toMatchObject({name: '*.example.test', ttl: 60, values: ['10.0.0.9'], managed: false, editable: true})
|
||||||
|
expect(items[3].body).toMatchObject({alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2FDTNDATAQYW2', evaluateTargetHealth: false}, editable: false})
|
||||||
|
expect(items[4].body).toMatchObject({setIdentifier: 'blue', editable: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pages with an opaque cursor built from Route 53\'s next record', async () => {
|
||||||
|
const {client, sent} = zoneStub(apex, (command) => command instanceof ListResourceRecordSetsCommand && !command.input.StartRecordName
|
||||||
|
? {ResourceRecordSets: apex, IsTruncated: true, NextRecordName: 'www.example.test.', NextRecordType: 'A'}
|
||||||
|
: undefined)
|
||||||
|
const store = new AwsDnsAdapter(client).items
|
||||||
|
|
||||||
|
const first = await store.listItems(ZONE_ID, {limit: 2})
|
||||||
|
expect(first.nextCursor).toBeTruthy()
|
||||||
|
await store.listItems(ZONE_ID, {cursor: first.nextCursor!})
|
||||||
|
const second = sent.filter((command) => command instanceof ListResourceRecordSetsCommand).at(-1) as ListResourceRecordSetsCommand
|
||||||
|
expect(second.input).toMatchObject({StartRecordName: 'www.example.test.', StartRecordType: 'A'})
|
||||||
|
await expect(store.listItems(ZONE_ID, {cursor: 'not-a-cursor'})).rejects.toThrow(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upserts a record, naming it inside the zone and quoting TXT text', async () => {
|
||||||
|
const {client, sent} = zoneStub()
|
||||||
|
const store = new AwsDnsAdapter(client).items
|
||||||
|
|
||||||
|
expect((await store.putItem!(ZONE_ID, {name: 'www', type: 'a', ttl: '120', values: ['10.0.0.1', '10.0.0.2']})).id).toBe('www.example.test|A')
|
||||||
|
await store.putItem!(ZONE_ID, {name: '@', type: 'TXT', values: 'v=spf1 -all\n"already quoted"'})
|
||||||
|
await store.putItem!(ZONE_ID, {name: 'mail.example.test.', type: 'MX', values: ['10 mx.example.test']})
|
||||||
|
|
||||||
|
const changes = sent.filter((command) => command instanceof ChangeResourceRecordSetsCommand)
|
||||||
|
.map((command) => (command as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0])
|
||||||
|
expect(changes[0]).toEqual({Action: 'UPSERT', ResourceRecordSet: {Name: 'www.example.test', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}, {Value: '10.0.0.2'}]}})
|
||||||
|
expect(changes[1].ResourceRecordSet).toMatchObject({Name: 'example.test', Type: 'TXT', TTL: 300, ResourceRecords: [{Value: '"v=spf1 -all"'}, {Value: '"already quoted"'}]})
|
||||||
|
expect(changes[2].ResourceRecordSet).toMatchObject({Name: 'mail.example.test', Type: 'MX'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies Route 53\'s value rules before calling it', async () => {
|
||||||
|
const {client, sent} = zoneStub()
|
||||||
|
const store = new AwsDnsAdapter(client).items
|
||||||
|
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: ['not-an-ip']})).rejects.toThrow(/IPv4/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'AAAA', values: ['10.0.0.1']})).rejects.toThrow(/IPv6/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'CNAME', values: ['a.test', 'b.test']})).rejects.toThrow(/exactly one/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: '@', type: 'CNAME', values: ['a.test']})).rejects.toThrow(/apex/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'mail', type: 'MX', values: ['mx.example.test']})).rejects.toThrow(/priority/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'BOGUS', values: ['x']})).rejects.toThrow(/type/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: []})).rejects.toThrow(/value/)
|
||||||
|
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', ttl: -1, values: ['10.0.0.1']})).rejects.toThrow(/ttl/)
|
||||||
|
expect(sent.some((command) => command instanceof ChangeResourceRecordSetsCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes exactly the stored record set, and never the zone\'s own NS or SOA', async () => {
|
||||||
|
const stored: ResourceRecordSet = {Name: 'www.example.test.', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}]}
|
||||||
|
const {client, sent} = zoneStub([stored])
|
||||||
|
const store = new AwsDnsAdapter(client).items
|
||||||
|
|
||||||
|
await store.deleteItem!(ZONE_ID, 'www.example.test|A')
|
||||||
|
const change = (sent.find((command) => command instanceof ChangeResourceRecordSetsCommand) as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0]
|
||||||
|
expect(change).toEqual({Action: 'DELETE', ResourceRecordSet: stored})
|
||||||
|
|
||||||
|
await expect(store.deleteItem!(ZONE_ID, 'example.test|NS')).rejects.toThrow(ConflictError)
|
||||||
|
await expect(store.deleteItem!(ZONE_ID, 'gone.example.test|A')).rejects.toThrow(/not found/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import {isIPv4, isIPv6} from 'node:net'
|
||||||
|
import {
|
||||||
|
ChangeResourceRecordSetsCommand,
|
||||||
|
CreateHostedZoneCommand,
|
||||||
|
DeleteHostedZoneCommand,
|
||||||
|
GetHostedZoneCommand,
|
||||||
|
ListHostedZonesCommand,
|
||||||
|
ListResourceRecordSetsCommand,
|
||||||
|
type HostedZone,
|
||||||
|
type ResourceRecordSet,
|
||||||
|
type Route53Client,
|
||||||
|
type RRType,
|
||||||
|
} from '@aws-sdk/client-route-53'
|
||||||
|
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||||
|
import {DNS_RECORD_TYPES, DNS_ZONE_NAME_PATTERN, awsDnsSchema} from '../cloud-spi/dnsSchema'
|
||||||
|
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** Route 53 caps ListResourceRecordSets pages at 300 and ListHostedZones at 100. */
|
||||||
|
const RECORD_PAGE_LIMIT = 300
|
||||||
|
const ZONE_PAGE_LIMIT = 100
|
||||||
|
/** Route 53's TTL range. */
|
||||||
|
const MAX_TTL = 2_147_483_647
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Route 53 hosted zones, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Zone IDs come back as `/hostedzone/Z…`; the bare ID is accepted everywhere, so
|
||||||
|
* resources are keyed by it.
|
||||||
|
* - A new zone holds the apex NS and SOA records, and `DeleteHostedZone` refuses
|
||||||
|
* with `HostedZoneNotEmpty` (400) while any other record remains, as on AWS.
|
||||||
|
* - A missing zone is `NoSuchHostedZone` with HTTP 404.
|
||||||
|
* - The runtime stores record values without checking them (an A record of
|
||||||
|
* `not-an-ip`, unquoted TXT text), so the AWS rules are applied here.
|
||||||
|
*
|
||||||
|
* Records are the zone's child items, keyed by name, type and set identifier,
|
||||||
|
* which is what makes a record set unique in Route 53.
|
||||||
|
*/
|
||||||
|
export class AwsDnsAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'dns' as const
|
||||||
|
readonly items: ItemStoreAdapter
|
||||||
|
|
||||||
|
constructor(private readonly route53: Route53Client, private readonly region = 'us-east-1') {
|
||||||
|
this.items = new RecordStore(route53, (id) => this.zoneName(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsDnsSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const zones: HostedZone[] = []
|
||||||
|
let marker: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.route53.send(new ListHostedZonesCommand({MaxItems: ZONE_PAGE_LIMIT, ...(marker ? {Marker: marker} : {})}))
|
||||||
|
zones.push(...(res.HostedZones ?? []))
|
||||||
|
marker = res.IsTruncated ? res.NextMarker : undefined
|
||||||
|
} while (marker)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return zones.map((zone) => toResource(zone))
|
||||||
|
.filter((zone) => !search || zone.name.toLowerCase().includes(search) || zone.id.toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
const res = await this.route53.send(new GetHostedZoneCommand({Id: zoneId(id)}))
|
||||||
|
return res.HostedZone ? toResource(res.HostedZone, res.DelegationSet?.NameServers, res.VPCs?.map((vpc) => `${vpc.VPCId} (${vpc.VPCRegion})`)) : null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'NoSuchHostedZone') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const name = typeof input.values.name === 'string' ? input.values.name.trim().toLowerCase() : ''
|
||||||
|
if (!new RegExp(DNS_ZONE_NAME_PATTERN).test(name)) throw new ValidationError('name must be a domain name such as example.com')
|
||||||
|
const comment = typeof input.values.comment === 'string' ? input.values.comment.trim() : ''
|
||||||
|
const vpcId = typeof input.values.vpcId === 'string' ? input.values.vpcId.trim() : ''
|
||||||
|
if (vpcId && !/^vpc-[0-9a-f]{8,17}$/.test(vpcId)) throw new ValidationError('vpcId must be a VPC ID such as vpc-0abc1234')
|
||||||
|
|
||||||
|
const res = await this.route53.send(new CreateHostedZoneCommand({
|
||||||
|
Name: name,
|
||||||
|
CallerReference: crypto.randomUUID(),
|
||||||
|
HostedZoneConfig: {Comment: comment || undefined, PrivateZone: Boolean(vpcId)},
|
||||||
|
...(vpcId ? {VPC: {VPCId: vpcId, VPCRegion: this.region as never}} : {}),
|
||||||
|
}))
|
||||||
|
if (!res.HostedZone) throw new ValidationError(`Route 53 did not return the hosted zone ${name}`)
|
||||||
|
return toResource(res.HostedZone, res.DelegationSet?.NameServers, vpcId ? [`${vpcId} (${this.region})`] : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.route53.send(new DeleteHostedZoneCommand({Id: zoneId(id)}))
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'HostedZoneNotEmpty') {
|
||||||
|
throw new ConflictError('Delete the zone\'s records first: Route 53 only deletes a hosted zone that holds just its NS and SOA records')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async zoneName(id: string): Promise<string> {
|
||||||
|
const zone = await this.get(id)
|
||||||
|
if (!zone) throw new NotFoundError(`Hosted zone ${id} was not found`)
|
||||||
|
return zone.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordStore implements ItemStoreAdapter {
|
||||||
|
constructor(private readonly route53: Route53Client, private readonly zoneName: (id: string) => Promise<string>) {}
|
||||||
|
|
||||||
|
async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||||
|
const start = decodeCursor(page.cursor)
|
||||||
|
const res = await this.route53.send(new ListResourceRecordSetsCommand({
|
||||||
|
HostedZoneId: zoneId(resourceId),
|
||||||
|
MaxItems: Math.min(page.limit ?? RECORD_PAGE_LIMIT, RECORD_PAGE_LIMIT),
|
||||||
|
...(start ? {StartRecordName: start.name, StartRecordType: start.type as RRType, StartRecordIdentifier: start.identifier} : {}),
|
||||||
|
}))
|
||||||
|
const zone = await this.zoneName(resourceId)
|
||||||
|
return {
|
||||||
|
items: (res.ResourceRecordSets ?? []).map((record) => toRecordItem(record, zone)),
|
||||||
|
nextCursor: res.IsTruncated && res.NextRecordName
|
||||||
|
? encodeCursor({name: res.NextRecordName, type: res.NextRecordType ?? '', identifier: res.NextRecordIdentifier})
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the record set, or replaces the one with the same name and type (UPSERT). */
|
||||||
|
async putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||||
|
const zone = await this.zoneName(resourceId)
|
||||||
|
const record = recordFromInput(body, zone)
|
||||||
|
await this.route53.send(new ChangeResourceRecordSetsCommand({
|
||||||
|
HostedZoneId: zoneId(resourceId),
|
||||||
|
ChangeBatch: {Changes: [{Action: 'UPSERT', ResourceRecordSet: record}]},
|
||||||
|
}))
|
||||||
|
return toRecordItem(record, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route 53 deletes a record set only when given it exactly, so the stored set is read first. */
|
||||||
|
async deleteItem(resourceId: string, itemId: string): Promise<void> {
|
||||||
|
const key = parseRecordId(itemId)
|
||||||
|
const zone = await this.zoneName(resourceId)
|
||||||
|
if (key.name === zone && (key.type === 'NS' || key.type === 'SOA')) {
|
||||||
|
throw new ConflictError(`The zone's own ${key.type} record cannot be deleted`)
|
||||||
|
}
|
||||||
|
const res = await this.route53.send(new ListResourceRecordSetsCommand({
|
||||||
|
HostedZoneId: zoneId(resourceId),
|
||||||
|
StartRecordName: key.name,
|
||||||
|
StartRecordType: key.type as RRType,
|
||||||
|
...(key.identifier ? {StartRecordIdentifier: key.identifier} : {}),
|
||||||
|
MaxItems: 1,
|
||||||
|
}))
|
||||||
|
const stored = res.ResourceRecordSets?.find((record) => recordId(record) === itemId)
|
||||||
|
if (!stored) throw new NotFoundError(`Record ${key.type} ${key.name} was not found`)
|
||||||
|
await this.route53.send(new ChangeResourceRecordSetsCommand({
|
||||||
|
HostedZoneId: zoneId(resourceId),
|
||||||
|
ChangeBatch: {Changes: [{Action: 'DELETE', ResourceRecordSet: stored}]},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(zone: HostedZone, nameServers?: string[], vpcs?: string[]): CloudResource {
|
||||||
|
const id = zoneId(zone.Id ?? '')
|
||||||
|
const privateZone = zone.Config?.PrivateZone ?? false
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: withoutDot(zone.Name ?? id),
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'dns',
|
||||||
|
type: 'hosted-zone',
|
||||||
|
region: null,
|
||||||
|
createdAt: null,
|
||||||
|
status: null,
|
||||||
|
metadata: {
|
||||||
|
visibility: privateZone ? 'private' : 'public',
|
||||||
|
recordCount: zone.ResourceRecordSetCount ?? null,
|
||||||
|
comment: zone.Config?.Comment ?? null,
|
||||||
|
...(nameServers ? {nameServers} : {}),
|
||||||
|
...(vpcs ? {vpcs} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecordItem(record: ResourceRecordSet, zone: string): ChildItem {
|
||||||
|
const name = withoutDot(unescapeName(record.Name ?? ''))
|
||||||
|
return {
|
||||||
|
id: recordId(record),
|
||||||
|
collectionId: null,
|
||||||
|
timestamp: null,
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
type: record.Type ?? '',
|
||||||
|
ttl: record.TTL ?? null,
|
||||||
|
values: (record.ResourceRecords ?? []).map((value) => value.Value ?? ''),
|
||||||
|
alias: record.AliasTarget ? {dnsName: withoutDot(record.AliasTarget.DNSName ?? ''), hostedZoneId: record.AliasTarget.HostedZoneId ?? '', evaluateTargetHealth: record.AliasTarget.EvaluateTargetHealth ?? false} : null,
|
||||||
|
setIdentifier: record.SetIdentifier ?? null,
|
||||||
|
// The zone's own NS and SOA records, and routing-policy sets this console cannot edit faithfully.
|
||||||
|
managed: (name === zone && (record.Type === 'NS' || record.Type === 'SOA')),
|
||||||
|
editable: !record.AliasTarget && !record.SetIdentifier,
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates a simple record the way Route 53 does and returns it as a record set. */
|
||||||
|
function recordFromInput(body: Record<string, unknown>, zone: string): ResourceRecordSet {
|
||||||
|
const type = typeof body.type === 'string' ? body.type.trim().toUpperCase() : ''
|
||||||
|
if (!(DNS_RECORD_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${DNS_RECORD_TYPES.join(', ')}`)
|
||||||
|
|
||||||
|
const name = qualifiedName(typeof body.name === 'string' ? body.name : '', zone)
|
||||||
|
const ttl = Number(body.ttl ?? 300)
|
||||||
|
if (!Number.isInteger(ttl) || ttl < 0 || ttl > MAX_TTL) throw new ValidationError('ttl must be a whole number of seconds')
|
||||||
|
|
||||||
|
const rawValues = Array.isArray(body.values) ? body.values : typeof body.values === 'string' ? body.values.split('\n') : []
|
||||||
|
const values = rawValues.map((value) => String(value).trim()).filter(Boolean)
|
||||||
|
if (values.length === 0) throw new ValidationError('Add at least one value')
|
||||||
|
if (type === 'CNAME' && values.length > 1) throw new ValidationError('A CNAME record has exactly one value')
|
||||||
|
if (type === 'CNAME' && name === zone) throw new ValidationError('The zone apex cannot have a CNAME record; use an A or alias record')
|
||||||
|
for (const value of values) {
|
||||||
|
if (type === 'A' && !isIPv4(value)) throw new ValidationError(`${value} is not an IPv4 address`)
|
||||||
|
if (type === 'AAAA' && !isIPv6(value)) throw new ValidationError(`${value} is not an IPv6 address`)
|
||||||
|
if (type === 'MX' && !/^\d{1,5}\s+\S+$/.test(value)) throw new ValidationError(`MX values are a priority and a mail server, such as "10 mail.${zone}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Name: name,
|
||||||
|
Type: type as RRType,
|
||||||
|
TTL: ttl,
|
||||||
|
ResourceRecords: values.map((value) => ({Value: type === 'TXT' || type === 'SPF' ? quoted(value) : value})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "www", "www.example.com" and "@" all name a record in example.com. */
|
||||||
|
function qualifiedName(input: string, zone: string): string {
|
||||||
|
const name = withoutDot(input.trim().toLowerCase())
|
||||||
|
if (!name || name === '@') return zone
|
||||||
|
const full = name === zone || name.endsWith(`.${zone}`) ? name : `${name}.${zone}`
|
||||||
|
if (!/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(full) || full.length > 253) {
|
||||||
|
throw new ValidationError(`${input} is not a valid record name`)
|
||||||
|
}
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route 53 wants each TXT string quoted; text the user already quoted is kept as written. */
|
||||||
|
function quoted(value: string): string {
|
||||||
|
if (value.startsWith('"') && value.endsWith('"') && value.length > 1) return value
|
||||||
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordId(record: Pick<ResourceRecordSet, 'Name' | 'Type' | 'SetIdentifier'>): string {
|
||||||
|
return [withoutDot(unescapeName(record.Name ?? '')), record.Type ?? '', ...(record.SetIdentifier ? [record.SetIdentifier] : [])].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecordId(id: string): {name: string; type: string; identifier?: string} {
|
||||||
|
const [name, type, ...rest] = id.split('|')
|
||||||
|
if (!name || !type) throw new ValidationError('Record id must be name|type')
|
||||||
|
return {name, type, ...(rest.length ? {identifier: rest.join('|')} : {})}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(cursor: {name: string; type: string; identifier?: string}): string {
|
||||||
|
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(cursor: string | undefined): {name: string; type: string; identifier?: string} | null {
|
||||||
|
if (!cursor) return null
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as {name?: unknown; type?: unknown; identifier?: unknown}
|
||||||
|
if (typeof value.name !== 'string' || typeof value.type !== 'string') throw new Error('bad cursor')
|
||||||
|
return {name: value.name, type: value.type, ...(typeof value.identifier === 'string' ? {identifier: value.identifier} : {})}
|
||||||
|
} catch {
|
||||||
|
throw new ValidationError('cursor is not valid for this hosted zone')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route 53 returns `*` as `\052` and other special characters as octal escapes. */
|
||||||
|
function unescapeName(name: string): string {
|
||||||
|
return name.replace(/\\(\d{3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneId(id: string): string {
|
||||||
|
return id.replace(/^\/hostedzone\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutDot(name: string): string {
|
||||||
|
return name.endsWith('.') ? name.slice(0, -1) : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string {
|
||||||
|
return (error as {name?: string} | null)?.name ?? ''
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ import {AwsLightsailAdapter} from './AwsLightsailAdapter'
|
|||||||
const originalEndpoint = process.env.FLOCI_ENDPOINT
|
const originalEndpoint = process.env.FLOCI_ENDPOINT
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
// Assigning undefined would store the string "undefined" for every later test file.
|
||||||
|
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||||
|
else process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
function stubLightsail() {
|
function stubLightsail() {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const realFetch = globalThis.fetch
|
|||||||
let passwordHash: string
|
let passwordHash: string
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
passwordHash = await Bun.password.hash('correct-horse-battery')
|
// Cheap parameters keep the many sign-in attempts below fast on slow CI runners; verification
|
||||||
|
// reads the parameters from the hash, so the code under test is unchanged.
|
||||||
|
passwordHash = await Bun.password.hash('correct-horse-battery', {algorithm: 'argon2id', memoryCost: 1024, timeCost: 1})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -239,7 +241,8 @@ describe('auth state cleanup', () => {
|
|||||||
test('caps pending OIDC requests and sweeps expired state', async () => {
|
test('caps pending OIDC requests and sweeps expired state', async () => {
|
||||||
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
||||||
const hono = app(config)
|
const hono = app(config)
|
||||||
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch
|
// The same document as mockIdp: discovery is cached per issuer for the rest of the run.
|
||||||
|
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'})) as unknown as typeof fetch
|
||||||
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
|
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
|
||||||
|
|
||||||
expect(authStateSizes().oidcRequests).toBe(1_000)
|
expect(authStateSizes().oidcRequests).toBe(1_000)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
|||||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
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 endpoint = process.env.FLOCI_ENDPOINT;
|
||||||
const region = process.env.AWS_REGION || "us-east-1";
|
const region = process.env.AWS_REGION || "us-east-1";
|
||||||
@@ -93,6 +95,8 @@ export type AwsClients = {
|
|||||||
elasticache: ElastiCacheClient;
|
elasticache: ElastiCacheClient;
|
||||||
lightsail: LightsailClient;
|
lightsail: LightsailClient;
|
||||||
cognito: CognitoIdentityProviderClient;
|
cognito: CognitoIdentityProviderClient;
|
||||||
|
route53: Route53Client;
|
||||||
|
cloudfront: CloudFrontClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AwsClientName = keyof AwsClients;
|
export type AwsClientName = keyof AwsClients;
|
||||||
@@ -139,6 +143,8 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
elasticache: new ElastiCacheClient(base),
|
elasticache: new ElastiCacheClient(base),
|
||||||
lightsail: new LightsailClient(base),
|
lightsail: new LightsailClient(base),
|
||||||
cognito: new CognitoIdentityProviderClient(base),
|
cognito: new CognitoIdentityProviderClient(base),
|
||||||
|
route53: new Route53Client(base),
|
||||||
|
cloudfront: new CloudFrontClient(base),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** A domain name of dot-separated labels, with or without the trailing dot. */
|
||||||
|
export const DNS_ZONE_NAME_PATTERN = '^(?=.{1,253}\\.?$)([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_])?\\.?$'
|
||||||
|
|
||||||
|
/** The record types Route 53 accepts in ChangeResourceRecordSets. */
|
||||||
|
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CAA', 'CNAME', 'DS', 'HTTPS', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT'] as const
|
||||||
|
|
||||||
|
const zoneColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Domain'},
|
||||||
|
{name: 'visibility', label: 'Type', path: 'metadata.visibility', format: 'badge'},
|
||||||
|
{name: 'recordCount', label: 'Records', path: 'metadata.recordCount'},
|
||||||
|
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
|
||||||
|
{name: 'id', label: 'Hosted zone ID', format: 'code'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const zoneFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsDnsSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'dns',
|
||||||
|
displayName: 'Amazon Route 53 hosted zones',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Domain name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
description: 'For example example.com. TheCloud does not resolve DNS: records are stored and returned by the Route 53 API.',
|
||||||
|
validation: {pattern: DNS_ZONE_NAME_PATTERN, message: 'Use a domain name such as example.com'},
|
||||||
|
},
|
||||||
|
{name: 'comment', label: 'Description', type: 'text', required: false},
|
||||||
|
{
|
||||||
|
name: 'vpcId',
|
||||||
|
label: 'VPC ID (private zone)',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
description: 'Leave empty for a public zone. With a VPC ID the zone is private and answers only inside that VPC.',
|
||||||
|
validation: {pattern: '^vpc-[0-9a-f]{8,17}$', message: 'Use a VPC ID such as vpc-0abc1234'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List hosted zones', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
itemActions: [
|
||||||
|
{name: 'list', label: 'List records', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'put', label: 'Create or update a record', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete a record', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: zoneFilters,
|
||||||
|
columns: zoneColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dnsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsDnsSchema() : null
|
||||||
|
}
|
||||||
@@ -239,6 +239,20 @@ export const SERVICE_CATALOG = {
|
|||||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||||
routeByCloud: {aws: '/secretsmanager'},
|
routeByCloud: {aws: '/secretsmanager'},
|
||||||
},
|
},
|
||||||
|
cdn: {
|
||||||
|
displayName: 'CDN',
|
||||||
|
displayNameByCloud: {aws: 'CloudFront'},
|
||||||
|
iconKey: 'cdn',
|
||||||
|
group: 'Networking',
|
||||||
|
order: 50,
|
||||||
|
},
|
||||||
|
dns: {
|
||||||
|
displayName: 'DNS',
|
||||||
|
displayNameByCloud: {aws: 'Route 53'},
|
||||||
|
iconKey: 'dns',
|
||||||
|
group: 'Networking',
|
||||||
|
order: 40,
|
||||||
|
},
|
||||||
userpools: {
|
userpools: {
|
||||||
displayName: 'User Directories',
|
displayName: 'User Directories',
|
||||||
displayNameByCloud: {aws: 'Cognito'},
|
displayNameByCloud: {aws: 'Cognito'},
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export type UserPoolActionName =
|
|||||||
| 'testSignIn'
|
| 'testSignIn'
|
||||||
| 'describeClient'
|
| 'describeClient'
|
||||||
| 'updateClientOAuth'
|
| 'updateClientOAuth'
|
||||||
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
export interface CapabilitySchema<TAction extends string> {
|
export interface CapabilitySchema<TAction extends string> {
|
||||||
@@ -224,6 +225,7 @@ export interface ServiceSchema {
|
|||||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||||
|
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||||
// Child levels advertise separately from the resource level because a
|
// Child levels advertise separately from the resource level because a
|
||||||
// store can be readable at the leaf and writable at the collection —
|
// store can be readable at the leaf and writable at the collection —
|
||||||
// CloudWatch Logs creates streams but cannot delete an event.
|
// 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. */
|
/** 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'
|
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||||
|
|
||||||
/** The app client settings that decide how the hosted login and OAuth endpoints treat it. */
|
/** 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<void>
|
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
|
||||||
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
||||||
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
||||||
|
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
|
||||||
|
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||||
clearEmailInbox?(): Promise<void>
|
clearEmailInbox?(): Promise<void>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
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.
|
* Guards the schema-to-implementation contract for every registered adapter.
|
||||||
@@ -47,6 +47,11 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
|||||||
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
||||||
|
setEnabled: 'setDistributionEnabled',
|
||||||
|
createInvalidation: 'createDistributionInvalidation',
|
||||||
|
}
|
||||||
|
|
||||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||||
updateUser: 'updateUserPoolUser',
|
updateUser: 'updateUserPoolUser',
|
||||||
setUserGroups: 'setUserPoolUserGroups',
|
setUserGroups: 'setUserPoolUserGroups',
|
||||||
@@ -141,6 +146,15 @@ describe('registered adapters honour their schema', () => {
|
|||||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
).toBe('function')
|
).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`, () => {
|
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?.databaseActions ?? []),
|
||||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
|||||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||||
|
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||||
|
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||||
import {createEc2Service} from './services/ec2'
|
import {createEc2Service} from './services/ec2'
|
||||||
import {createEksService} from './services/eks'
|
import {createEksService} from './services/eks'
|
||||||
@@ -98,6 +100,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsElastiCacheAdapter(clients.elasticache),
|
new AwsElastiCacheAdapter(clients.elasticache),
|
||||||
new AwsLightsailAdapter(clients.lightsail),
|
new AwsLightsailAdapter(clients.lightsail),
|
||||||
new AwsUserPoolsAdapter(clients.cognito),
|
new AwsUserPoolsAdapter(clients.cognito),
|
||||||
|
new AwsDnsAdapter(clients.route53),
|
||||||
|
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||||
new AzureStorageAdapter(),
|
new AzureStorageAdapter(),
|
||||||
new AzureServiceBusAdapter(),
|
new AzureServiceBusAdapter(),
|
||||||
new AzureDatabaseAdapter(),
|
new AzureDatabaseAdapter(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
|||||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||||
|
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||||
@@ -769,6 +770,31 @@ describe('cloud schema routes', () => {
|
|||||||
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
|
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 () => {
|
test('clears the SES mailbox through the email adapter', async () => {
|
||||||
let cleared = false
|
let cleared = false
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
|||||||
@@ -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())))
|
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) => {
|
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
||||||
|
|
||||||
// The secretsmanager router imports the `secretsManager` singleton directly from
|
// The secretsmanager router imports the `secretsManager` singleton directly from
|
||||||
// `../aws`, so we replace that module with a stub whose `send()` is driven per
|
// `../aws`, so we replace that export with a stub whose `send()` is driven per
|
||||||
// test. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
// test. Bun keeps a module mock for the rest of the run, so every other export
|
||||||
|
// stays real: test files that load `../aws` after this one still get it whole. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
||||||
// the command that was dispatched so we can assert on its input shape.
|
// the command that was dispatched so we can assert on its input shape.
|
||||||
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
||||||
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
||||||
|
|
||||||
|
const realAws = await import('../aws')
|
||||||
mock.module('../aws', () => ({
|
mock.module('../aws', () => ({
|
||||||
|
...realAws,
|
||||||
secretsManager: {
|
secretsManager: {
|
||||||
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
||||||
lastCommand = command
|
lastCommand = command
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import type {
|
|||||||
UserPoolSignInTestInput,
|
UserPoolSignInTestInput,
|
||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
|
DistributionInvalidation,
|
||||||
UpdateUserPoolClientOAuthInput,
|
UpdateUserPoolClientOAuthInput,
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
@@ -833,6 +834,18 @@ export class CloudProxyService {
|
|||||||
return adapter.testUserPoolSignIn(poolId, input)
|
return adapter.testUserPoolSignIn(poolId, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
|
||||||
|
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<DistributionInvalidation> {
|
||||||
|
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<UserPoolClientSettings> {
|
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||||
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ const originalEndpoint = process.env.FLOCI_ENDPOINT
|
|||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
// Assigning undefined would store the string "undefined" for every later test file.
|
||||||
|
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||||
|
else process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"},
|
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",
|
moveTasks: "clouds.services.messaging.move-tasks",
|
||||||
},
|
},
|
||||||
|
cdn: {
|
||||||
|
enabled: "clouds.services.cdn.enabled",
|
||||||
|
invalidations: "clouds.services.cdn.invalidations",
|
||||||
|
},
|
||||||
userpools: {
|
userpools: {
|
||||||
userAction: "clouds.services.userpools.users.action",
|
userAction: "clouds.services.userpools.users.action",
|
||||||
userGroups: "clouds.services.userpools.users.groups",
|
userGroups: "clouds.services.userpools.users.groups",
|
||||||
@@ -886,6 +890,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "POST",
|
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,
|
apiEndpointKeys.clouds.userpools.client.get,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
UserPoolSignInTestInput,
|
UserPoolSignInTestInput,
|
||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
|
DistributionInvalidation,
|
||||||
UpdateUserPoolClientOAuthInput,
|
UpdateUserPoolClientOAuthInput,
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
LogsInsightsQueryInput,
|
LogsInsightsQueryInput,
|
||||||
@@ -1476,6 +1477,32 @@ export async function testUserPoolSignIn(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setDistributionEnabled(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
distributionId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, { enabled: boolean }>(
|
||||||
|
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<DistributionInvalidation> {
|
||||||
|
const res = await apiClient.call<DistributionInvalidation, { paths: string[] }>(
|
||||||
|
apiEndpointKeys.clouds.cdn.invalidations,
|
||||||
|
requestOptions(cloud, "cdn", { body: { paths } }),
|
||||||
|
{ cloud, id: distributionId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function describeUserPoolClient(
|
export async function describeUserPoolClient(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
poolId: string,
|
poolId: string,
|
||||||
|
|||||||
@@ -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: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(<QueryClientProvider client={client}><CloudFrontPanel resource={distribution} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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 <section className="table-panel"><div className="empty compact"><h3>Select a distribution</h3><p>Choose a distribution to open it, change its state or invalidate cached files.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Origin>(metadata.origins)
|
||||||
|
const behaviors = list<Behavior>(metadata.behaviors)
|
||||||
|
const invalidations = list<Invalidation>(metadata.invalidations)
|
||||||
|
const pending = enabledMutation.isPending || invalidationMutation.isPending
|
||||||
|
const disabled = !runtimeReachable || pending
|
||||||
|
const error = distributionQuery.error ?? enabledMutation.error ?? invalidationMutation.error
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Distribution ${distribution.id}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Distribution</p><span className="muted">{enabled ? 'Enabled' : 'Disabled'} · {distribution.status ?? 'Unknown'}{metadata.defaultRootObject ? ` · serves ${String(metadata.defaultRootObject)} at /` : ''}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); enabledMutation.mutate(!enabled) }}>{enabled ? 'Disable' : 'Enable'}</button>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || distributionQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{distributionQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{viewerUrl && <div className="cdn-viewer">
|
||||||
|
<span className="copy-value mono">{viewerUrl}<CopyValueButton value={viewerUrl} label="Local URL"/></span>
|
||||||
|
<a className="button" href={viewerUrl} target="_blank" rel="noreferrer"><ExternalLink size={13}/> Open</a>
|
||||||
|
<small className="muted">Served by TheCloud on this machine. On AWS the same distribution answers at <code>{String(metadata.domainName ?? distribution.name)}</code>.</small>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Origins</p></div></div>
|
||||||
|
{origins.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Origin</th><th>Domain</th><th>Type</th></tr></thead><tbody>{origins.map((origin) => (
|
||||||
|
<tr key={origin.id}><td className="mono">{origin.id}</td><td className="mono">{origin.domainName}</td><td>{origin.kind === 's3' ? 'S3 bucket' : `Custom (${origin.protocol ?? 'https-only'})`}</td></tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
{behaviors.length > 0 && <>
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Cache behaviours</p><span className="muted">The first path pattern that matches a request decides how it is served.</span></div></div>
|
||||||
|
<div className="cognito-table"><table className="table"><thead><tr><th>Path pattern</th><th>Origin</th><th>Viewer protocol</th><th>Methods</th></tr></thead><tbody>{behaviors.map((behavior) => (
|
||||||
|
<tr key={behavior.pathPattern}><td className="mono">{behavior.pathPattern}</td><td className="mono">{behavior.originId}</td><td>{VIEWER_PROTOCOL[behavior.viewerProtocol] ?? behavior.viewerProtocol}</td><td>{behavior.methods.join(', ')}</td></tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Invalidations</p><span className="muted">Clear cached copies so the next request fetches them from the origin.</span></div></div>
|
||||||
|
<form className="dynamic-form" aria-label="Create invalidation" onSubmit={(event) => { event.preventDefault(); setNotice(''); invalidationMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field dynamic-field--span"><span>Paths</span>
|
||||||
|
<textarea className="textarea code-textarea small" rows={2} value={paths} onChange={(event) => setPaths(event.target.value)} disabled={disabled} required/>
|
||||||
|
<small className="muted">One per line, each starting with /. Use /* for everything.</small>
|
||||||
|
</label>
|
||||||
|
<div><button className="button primary" type="submit" disabled={disabled || !paths.trim()}>{invalidationMutation.isPending ? 'Invalidating' : 'Create invalidation'}</button></div>
|
||||||
|
</form>
|
||||||
|
{invalidations.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Invalidation</th><th>Status</th><th>Created</th></tr></thead><tbody>{invalidations.map((invalidation) => (
|
||||||
|
<tr key={invalidation.id}><td className="mono">{invalidation.id}</td><td><code>{invalidation.status}</code></td><td>{formatDateTime(invalidation.createdAt) ?? '—'}</td></tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function list<T>(value: unknown): T[] {
|
||||||
|
return Array.isArray(value) ? value as T[] : []
|
||||||
|
}
|
||||||
@@ -65,6 +65,8 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
|||||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||||
|
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||||
|
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||||
@@ -979,6 +981,12 @@ export function DynamicResourceView({
|
|||||||
{service === "userpools" && cloud === "aws" && (
|
{service === "userpools" && cloud === "aws" && (
|
||||||
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "dns" && cloud === "aws" && (
|
||||||
|
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "cdn" && cloud === "aws" && (
|
||||||
|
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
{service === "compute" && (
|
{service === "compute" && (
|
||||||
<ComputePanel
|
<ComputePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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 {Route53RecordsPanel} from './Route53RecordsPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
deleteFlatItem: vi.fn(),
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listFlatItems: vi.fn(),
|
||||||
|
putFlatItem: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const zone: CloudResource = {
|
||||||
|
id: 'ZG9PAE0M9KRWJ4F', name: 'example.test', cloud: 'aws', service: 'dns', type: 'hosted-zone',
|
||||||
|
region: null, createdAt: null, metadata: {visibility: 'public', recordCount: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(id: string, body: Record<string, unknown>) {
|
||||||
|
return {id, collectionId: null, timestamp: null, metadata: {}, body: {ttl: 300, values: [], alias: null, setIdentifier: null, managed: false, editable: true, ...body}}
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = [
|
||||||
|
item('example.test|NS', {name: 'example.test', type: 'NS', ttl: 172800, values: ['ns-1.awsdns-01.org.'], managed: true}),
|
||||||
|
item('www.example.test|A', {name: 'www.example.test', type: 'A', values: ['10.0.0.1']}),
|
||||||
|
item('example.test|TXT', {name: 'example.test', type: 'TXT', values: ['"v=spf1 -all"']}),
|
||||||
|
item('cdn.example.test|A', {name: 'cdn.example.test', type: 'A', ttl: null, alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2'}, editable: false}),
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><Route53RecordsPanel resource={zone} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getCloudResource.mockResolvedValue({...zone, metadata: {...zone.metadata, nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}})
|
||||||
|
api.listFlatItems.mockResolvedValue({items: records, nextCursor: null})
|
||||||
|
api.putFlatItem.mockImplementation(async (_cloud, _service, _zone, body) => item('x', {name: `${body.name}.example.test`, type: body.type}))
|
||||||
|
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the name servers and records, keeping the zone defaults read-only', async () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByText('ns-2.awsdns-02.net')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Alias → d111.cloudfront.net')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('zone default')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', {name: 'Delete NS example.test'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', {name: 'Edit A cdn.example.test'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Delete A cdn.example.test'})).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a record from a name inside the zone', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Create record'})
|
||||||
|
await user.type(within(form).getByLabelText(/Record name/), 'api')
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Type'), 'CNAME')
|
||||||
|
await user.clear(within(form).getByLabelText(/TTL/))
|
||||||
|
await user.type(within(form).getByLabelText(/TTL/), '60')
|
||||||
|
await user.type(within(form).getByLabelText(/Values/), 'app.example.net')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create record'}))
|
||||||
|
|
||||||
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, {name: 'api', type: 'CNAME', ttl: 60, values: ['app.example.net']})
|
||||||
|
expect(await screen.findByText(/Created CNAME api.example.test/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits a record with its name and type fixed, showing TXT text without quotes', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Edit TXT example.test'}))
|
||||||
|
const form = screen.getByRole('form', {name: 'Edit record'})
|
||||||
|
expect(within(form).getByLabelText(/Record name/)).toHaveValue('@')
|
||||||
|
expect(within(form).getByLabelText(/Record name/)).toBeDisabled()
|
||||||
|
expect(within(form).getByLabelText('Type')).toBeDisabled()
|
||||||
|
expect(within(form).getByLabelText(/Values/)).toHaveValue('v=spf1 -all')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a record by its name and type', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete A www.example.test'}))
|
||||||
|
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, 'www.example.test|A')
|
||||||
|
expect(await screen.findByText('Deleted A www.example.test.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import {Pencil, RefreshCw, Trash2} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||||
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import type {ChildItem, CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DnsRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
ttl: number | null
|
||||||
|
values: string[]
|
||||||
|
alias: {dnsName: string; hostedZoneId: string} | null
|
||||||
|
setIdentifier: string | null
|
||||||
|
managed: boolean
|
||||||
|
editable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS', 'PTR', 'SPF', 'DS', 'HTTPS', 'SVCB', 'NAPTR', 'SSHFP', 'TLSA']
|
||||||
|
const VALUE_HINTS: Record<string, string> = {
|
||||||
|
A: 'IPv4 addresses, one per line: 192.0.2.10',
|
||||||
|
AAAA: 'IPv6 addresses, one per line: 2001:db8::10',
|
||||||
|
CNAME: 'One host name: app.example.net',
|
||||||
|
TXT: 'Text, one string per line. Quotes are added for you.',
|
||||||
|
MX: 'Priority and mail server, one per line: 10 mail.example.com',
|
||||||
|
SRV: 'Priority, weight, port and target: 10 5 5060 sip.example.com',
|
||||||
|
CAA: 'Flags, tag and value: 0 issue "amazon.com"',
|
||||||
|
}
|
||||||
|
/** Every page of a zone is fetched; zones on a local runtime are small. */
|
||||||
|
const MAX_PAGES = 20
|
||||||
|
|
||||||
|
const emptyDraft = {name: '', type: 'A', ttl: '300', values: ''}
|
||||||
|
|
||||||
|
export function Route53RecordsPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [draft, setDraft] = useState(emptyDraft)
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const recordsKey = ['dns-records', accountId, resource?.id]
|
||||||
|
|
||||||
|
const zoneQuery = useQuery({
|
||||||
|
queryKey: ['dns-zone', accountId, resource?.id],
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'dns', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const recordsQuery = useQuery({
|
||||||
|
queryKey: recordsKey,
|
||||||
|
queryFn: async ({signal}) => {
|
||||||
|
const items: ChildItem[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const result = await listFlatItems('aws', 'dns', resource?.id ?? '', cursor, signal)
|
||||||
|
items.push(...result.items)
|
||||||
|
if (!result.nextCursor) break
|
||||||
|
cursor = result.nextCursor
|
||||||
|
}
|
||||||
|
return items.map(toRecord)
|
||||||
|
},
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
setEditing(null)
|
||||||
|
setNotice('')
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
// The zone list shows each zone's record count.
|
||||||
|
const refresh = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({queryKey: recordsKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: ['dns-zone', accountId, resource?.id]}),
|
||||||
|
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'dns']}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => putFlatItem('aws', 'dns', resource?.id ?? '', {
|
||||||
|
name: draft.name,
|
||||||
|
type: draft.type,
|
||||||
|
ttl: Number(draft.ttl),
|
||||||
|
values: draft.values.split('\n'),
|
||||||
|
}),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
setNotice(`${editing ? 'Saved' : 'Created'} ${String(saved.body.type)} ${String(saved.body.name)}.`)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
setEditing(null)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteFlatItem('aws', 'dns', resource?.id ?? '', id),
|
||||||
|
onSuccess: (_result, id) => {
|
||||||
|
setNotice(`Deleted ${id.split('|').slice(0, 2).reverse().join(' ')}.`)
|
||||||
|
if (editing === id) { setEditing(null); setDraft(emptyDraft) }
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a hosted zone</h3><p>Choose a hosted zone to see and change its DNS records.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneName = resource.name
|
||||||
|
const records = recordsQuery.data ?? []
|
||||||
|
const nameServers = stringList(zoneQuery.data?.metadata.nameServers)
|
||||||
|
const pending = saveMutation.isPending || deleteMutation.isPending
|
||||||
|
const disabled = !runtimeReachable || pending
|
||||||
|
const error = zoneQuery.error ?? recordsQuery.error ?? saveMutation.error ?? deleteMutation.error
|
||||||
|
const edit = (record: DnsRecord) => {
|
||||||
|
setNotice('')
|
||||||
|
setEditing(record.id)
|
||||||
|
setDraft({name: relativeName(record.name, zoneName), type: record.type, ttl: String(record.ttl ?? 300), values: record.values.map((value) => record.type === 'TXT' || record.type === 'SPF' ? unquote(value) : value).join('\n')})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Records in ${zoneName}`}>
|
||||||
|
{nameServers.length > 0 && <>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Name servers</p><span className="muted">Delegate {zoneName} to these at your registrar. TheCloud stores records but does not answer DNS queries.</span></div>
|
||||||
|
</div>
|
||||||
|
<ul className="dns-name-servers">{nameServers.map((server) => <li key={server}><span className="copy-value mono">{server}<CopyValueButton value={server} label="Name server"/></span></li>)}</ul>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Records</p><span className="muted">{records.length} {records.length === 1 ? 'record' : 'records'} in {zoneName}</span></div>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || recordsQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{recordsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="dynamic-form" aria-label={editing ? 'Edit record' : 'Create record'} onSubmit={(event) => { event.preventDefault(); setNotice(''); saveMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field"><span>Record name</span>
|
||||||
|
<div className="dns-name-input"><input className="input" value={draft.name} placeholder="www" onChange={(event) => setDraft({...draft, name: event.target.value})} disabled={disabled || Boolean(editing)} autoComplete="off"/><span className="dns-zone-suffix">.{zoneName}</span></div>
|
||||||
|
<small className="muted">Leave empty or use @ for {zoneName} itself.</small>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>Type</span>
|
||||||
|
<select className="input" value={draft.type} onChange={(event) => setDraft({...draft, type: event.target.value})} disabled={disabled || Boolean(editing)}>
|
||||||
|
{RECORD_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>TTL (seconds)</span>
|
||||||
|
<input className="input" type="number" min={0} value={draft.ttl} onChange={(event) => setDraft({...draft, ttl: event.target.value})} disabled={disabled} required/>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field dynamic-field--span"><span>Values</span>
|
||||||
|
<textarea className="textarea code-textarea small" rows={3} value={draft.values} onChange={(event) => setDraft({...draft, values: event.target.value})} disabled={disabled} required/>
|
||||||
|
<small className="muted">{VALUE_HINTS[draft.type] ?? 'One value per line.'}</small>
|
||||||
|
</label>
|
||||||
|
<div className="dns-form-actions">
|
||||||
|
<button className="button primary" type="submit" disabled={disabled || !draft.values.trim()}>{saveMutation.isPending ? 'Saving' : editing ? 'Save record' : 'Create record'}</button>
|
||||||
|
{editing && <button className="button" type="button" disabled={pending} onClick={() => { setEditing(null); setDraft(emptyDraft) }}>Cancel</button>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
{recordsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
||||||
|
{records.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>TTL</th><th>Value</th><th/></tr></thead><tbody>{records.map((record) => (
|
||||||
|
<tr key={record.id} aria-current={editing === record.id ? 'true' : undefined}>
|
||||||
|
<td className="mono">{record.name}{record.managed && <span className="badge dns-badge">zone default</span>}{record.setIdentifier && <span className="badge dns-badge">{record.setIdentifier}</span>}</td>
|
||||||
|
<td><code>{record.type}</code></td>
|
||||||
|
<td>{record.alias ? '—' : record.ttl ?? '—'}</td>
|
||||||
|
<td className="dns-values">{record.alias
|
||||||
|
? <span className="mono">Alias → {record.alias.dnsName}</span>
|
||||||
|
: record.values.map((value, index) => <div key={index} className="mono">{value}</div>)}</td>
|
||||||
|
<td><div className="cognito-row-actions">
|
||||||
|
{record.editable && !record.managed && <button className="icon-btn" type="button" title="Edit record" aria-label={`Edit ${record.type} ${record.name}`} disabled={disabled} onClick={() => edit(record)}><Pencil size={14}/></button>}
|
||||||
|
{!record.managed && <button className="icon-btn danger" type="button" title="Delete record" aria-label={`Delete ${record.type} ${record.name}`} disabled={disabled} onClick={() => { setNotice(''); deleteMutation.mutate(record.id) }}><Trash2 size={14}/></button>}
|
||||||
|
</div></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(item: ChildItem): DnsRecord {
|
||||||
|
const body = item.body
|
||||||
|
const alias = body.alias && typeof body.alias === 'object' ? body.alias as {dnsName?: unknown; hostedZoneId?: unknown} : null
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: String(body.name ?? ''),
|
||||||
|
type: String(body.type ?? ''),
|
||||||
|
ttl: typeof body.ttl === 'number' ? body.ttl : null,
|
||||||
|
values: stringList(body.values),
|
||||||
|
alias: alias ? {dnsName: String(alias.dnsName ?? ''), hostedZoneId: String(alias.hostedZoneId ?? '')} : null,
|
||||||
|
setIdentifier: typeof body.setIdentifier === 'string' ? body.setIdentifier : null,
|
||||||
|
managed: body.managed === true,
|
||||||
|
editable: body.editable === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeName(name: string, zone: string): string {
|
||||||
|
if (name === zone) return '@'
|
||||||
|
return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquote(value: string): string {
|
||||||
|
return value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Container,
|
Container,
|
||||||
Database,
|
Database,
|
||||||
|
Earth,
|
||||||
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Hammer,
|
Hammer,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -31,6 +33,8 @@ import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.s
|
|||||||
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
||||||
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
||||||
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||||
|
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
||||||
|
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||||
@@ -87,6 +91,8 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
iam: ShieldCheck,
|
iam: ShieldCheck,
|
||||||
kms: Lock,
|
kms: Lock,
|
||||||
userpools: UsersRound,
|
userpools: UsersRound,
|
||||||
|
dns: Globe,
|
||||||
|
cdn: Earth,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
parameters: SlidersHorizontal,
|
parameters: SlidersHorizontal,
|
||||||
configuration: SlidersHorizontal,
|
configuration: SlidersHorizontal,
|
||||||
@@ -131,6 +137,8 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
serverless: lambdaIcon,
|
serverless: lambdaIcon,
|
||||||
storage: s3Icon,
|
storage: s3Icon,
|
||||||
userpools: cognitoIcon,
|
userpools: cognitoIcon,
|
||||||
|
dns: route53Icon,
|
||||||
|
cdn: cloudFrontIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6489,3 +6489,52 @@ button.console-service-card {
|
|||||||
.lightsail-port-form { align-items: end; margin: 16px 0; }
|
.lightsail-port-form { align-items: end; margin: 16px 0; }
|
||||||
.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
|
.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
|
||||||
@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
|
@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
|
||||||
|
|
||||||
|
.dns-name-servers {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-name-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-name-input .input { flex: 1; min-width: 0; }
|
||||||
|
.dynamic-field .dns-zone-suffix {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-values { overflow-wrap: anywhere; }
|
||||||
|
.dns-badge { margin-left: 8px; }
|
||||||
|
tr[aria-current="true"] td { background: var(--hover-md); }
|
||||||
|
|
||||||
|
.cdn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
|
||||||
|
.cdn-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||||
|
.cdn-viewer small { flex-basis: 100%; }
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ export interface UserPoolSignInTestInput {
|
|||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DistributionInvalidation {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
createdAt: string | null
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||||
|
|
||||||
export interface UserPoolClientSettings {
|
export interface UserPoolClientSettings {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type KubernetesActionName =
|
|||||||
| 'createFargateProfile'
|
| 'createFargateProfile'
|
||||||
| 'deleteFargateProfile'
|
| 'deleteFargateProfile'
|
||||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||||
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ export interface ServiceSchema {
|
|||||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||||
|
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||||
}
|
}
|
||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
columns: TableColumnSchema[]
|
columns: TableColumnSchema[]
|
||||||
|
|||||||
Generated
+101
-54
@@ -19,6 +19,9 @@ importers:
|
|||||||
'@aws-sdk/client-cloudformation':
|
'@aws-sdk/client-cloudformation':
|
||||||
specifier: ^3.1111.0
|
specifier: ^3.1111.0
|
||||||
version: 3.1111.0
|
version: 3.1111.0
|
||||||
|
'@aws-sdk/client-cloudfront':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1156.0
|
||||||
'@aws-sdk/client-cloudwatch':
|
'@aws-sdk/client-cloudwatch':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1146.0
|
version: 3.1146.0
|
||||||
@@ -88,6 +91,9 @@ importers:
|
|||||||
'@aws-sdk/client-rds-data':
|
'@aws-sdk/client-rds-data':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1144.0
|
version: 3.1144.0
|
||||||
|
'@aws-sdk/client-route-53':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1156.0
|
||||||
'@aws-sdk/client-s3':
|
'@aws-sdk/client-s3':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1076.0
|
version: 3.1076.0
|
||||||
@@ -301,6 +307,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-cloudfront@3.1156.0':
|
||||||
|
resolution: {integrity: sha512-z4V/ecv6b5uGwVVMaE8NbczaPVfM2HwbZAGUFp9XDjm+Q8WoFrF0vRlWaRWpW+rox4yPZc4tSo+cv8drud+r8A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
||||||
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
|
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -393,6 +403,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
|
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-route-53@3.1156.0':
|
||||||
|
resolution: {integrity: sha512-1IwDSboTwUovZhuyDBhjAgPxFeIdy5+XCmZNKYFdg0XJax27qx4Ir7/m5CA60pdAHWBTMpUKm0jtyNEOlZnsuA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-s3@3.1076.0':
|
'@aws-sdk/client-s3@3.1076.0':
|
||||||
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
|
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -708,6 +722,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
|
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/middleware-sdk-route53@3.972.28':
|
||||||
|
resolution: {integrity: sha512-93l3lB5ngw0XQDGvuK/R9R3UAN4+xeYgLOW8E6nzldlNhu0E8vfkYMTlh5km643i9F193b5ZO21wOWWT47RxHw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
||||||
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
|
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2905,6 +2923,17 @@ snapshots:
|
|||||||
'@smithy/types': 4.16.1
|
'@smithy/types': 4.16.1
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-cloudfront@3.1156.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/credential-provider-node': 3.972.88
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
|
'@smithy/node-http-handler': 4.12.1
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3174,6 +3203,18 @@ snapshots:
|
|||||||
'@smithy/types': 4.15.1
|
'@smithy/types': 4.15.1
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-route-53@3.1156.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/credential-provider-node': 3.972.88
|
||||||
|
'@aws-sdk/middleware-sdk-route53': 3.972.28
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
|
'@smithy/node-http-handler': 4.12.1
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-s3@3.1076.0':
|
'@aws-sdk/client-s3@3.1076.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/sha1-browser': 5.2.0
|
'@aws-crypto/sha1-browser': 5.2.0
|
||||||
@@ -3578,55 +3619,55 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.60':
|
'@aws-sdk/credential-provider-login@3.972.60':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.69':
|
'@aws-sdk/credential-provider-login@3.972.69':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.76':
|
'@aws-sdk/credential-provider-login@3.972.76':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.77':
|
'@aws-sdk/credential-provider-login@3.972.77':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.78':
|
'@aws-sdk/credential-provider-login@3.972.78':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.79':
|
'@aws-sdk/credential-provider-login@3.972.79':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.46
|
'@aws-sdk/nested-clients': 3.997.46
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
@@ -3990,6 +4031,12 @@ snapshots:
|
|||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.17.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/middleware-sdk-route53@3.972.28':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -4008,10 +4055,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/nested-clients@3.997.28':
|
'@aws-sdk/nested-clients@3.997.28':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.12.1
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
@@ -4019,10 +4066,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/nested-clients@3.997.36':
|
'@aws-sdk/nested-clients@3.997.36':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.12.1
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
@@ -4030,10 +4077,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/nested-clients@3.997.43':
|
'@aws-sdk/nested-clients@3.997.43':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.12.1
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
@@ -4041,10 +4088,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/nested-clients@3.997.44':
|
'@aws-sdk/nested-clients@3.997.44':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.12.1
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
@@ -4052,10 +4099,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/nested-clients@3.997.45':
|
'@aws-sdk/nested-clients@3.997.45':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.12.1
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
@@ -4063,10 +4110,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/nested-clients@3.997.46':
|
'@aws-sdk/nested-clients@3.997.46':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.12.1
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
@@ -4113,55 +4160,55 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/token-providers@3.1080.0':
|
'@aws-sdk/token-providers@3.1080.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1096.0':
|
'@aws-sdk/token-providers@3.1096.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1111.0':
|
'@aws-sdk/token-providers@3.1111.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1116.0':
|
'@aws-sdk/token-providers@3.1116.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1129.0':
|
'@aws-sdk/token-providers@3.1129.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.45
|
'@aws-sdk/nested-clients': 3.997.45
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1138.0':
|
'@aws-sdk/token-providers@3.1138.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.46
|
'@aws-sdk/nested-clients': 3.997.46
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user