feat(api): add Route 53 hosted zones and DNS records to the cloud explorer #15

Merged
Zakaria merged 1 commits from feat/route53 into main 2026-10-24 17:10:53 +00:00
14 changed files with 917 additions and 0 deletions
+1
View File
@@ -78,6 +78,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Networking | Networking | Yes (list) | 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 | Route 53 / DNS | Yes (list, create, delete, inspect) | No | No |
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
+1
View File
@@ -36,6 +36,7 @@
"@aws-sdk/client-lightsail": "^3.1076.0",
"@aws-sdk/client-rds": "^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-secrets-manager": "^3.1076.0",
"@aws-sdk/client-sesv2": "^3.1076.0",
@@ -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 ?? ''
}
+3
View File
@@ -31,6 +31,7 @@ import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import { Route53Client } from "@aws-sdk/client-route-53";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -93,6 +94,7 @@ export type AwsClients = {
elasticache: ElastiCacheClient;
lightsail: LightsailClient;
cognito: CognitoIdentityProviderClient;
route53: Route53Client;
};
export type AwsClientName = keyof AwsClients;
@@ -139,6 +141,7 @@ function buildClients(accountId: string): AwsClients {
elasticache: new ElastiCacheClient(base),
lightsail: new LightsailClient(base),
cognito: new CognitoIdentityProviderClient(base),
route53: new Route53Client(base),
};
}
+64
View File
@@ -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,13 @@ export const SERVICE_CATALOG = {
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
routeByCloud: {aws: '/secretsmanager'},
},
dns: {
displayName: 'DNS',
displayNameByCloud: {aws: 'Route 53'},
iconKey: 'dns',
group: 'Networking',
order: 40,
},
userpools: {
displayName: 'User Directories',
displayNameByCloud: {aws: 'Cognito'},
+2
View File
@@ -48,6 +48,7 @@ import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -98,6 +99,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsElastiCacheAdapter(clients.elasticache),
new AwsLightsailAdapter(clients.lightsail),
new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
@@ -65,6 +65,7 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -979,6 +980,9 @@ export function DynamicResourceView({
{service === "userpools" && cloud === "aws" && (
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
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,7 @@ import {
Clock,
Container,
Database,
Globe,
HardDrive,
Hammer,
KeyRound,
@@ -31,6 +32,7 @@ import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.s
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.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 cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -87,6 +89,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
iam: ShieldCheck,
kms: Lock,
userpools: UsersRound,
dns: Globe,
loadbalancing: Scale,
parameters: SlidersHorizontal,
configuration: SlidersHorizontal,
@@ -131,6 +134,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
serverless: lambdaIcon,
storage: s3Icon,
userpools: cognitoIcon,
dns: route53Icon,
workflows: stepFunctionsIcon,
}
+36
View File
@@ -6489,3 +6489,39 @@ button.console-service-card {
.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; }
@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); }
+29
View File
@@ -88,6 +88,9 @@ importers:
'@aws-sdk/client-rds-data':
specifier: ^3.1076.0
version: 3.1144.0
'@aws-sdk/client-route-53':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-s3':
specifier: ^3.1076.0
version: 3.1076.0
@@ -393,6 +396,10 @@ packages:
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
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':
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
engines: {node: '>=20.0.0'}
@@ -708,6 +715,10 @@ packages:
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
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':
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
engines: {node: '>=20.0.0'}
@@ -3174,6 +3185,18 @@ snapshots:
'@smithy/types': 4.15.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':
dependencies:
'@aws-crypto/sha1-browser': 5.2.0
@@ -3990,6 +4013,12 @@ snapshots:
'@smithy/types': 4.17.2
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':
dependencies:
'@aws-sdk/core': 3.978.0