Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e511fbfa |
@@ -82,7 +82,6 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| 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 |
|
||||||
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
|
|
||||||
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
||||||
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
||||||
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
@@ -96,7 +95,6 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Certificate Manager / Certificates | Yes (list, create, delete, inspect) | No | No |
|
|
||||||
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
|
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
|
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-acm": "^3.1146.0",
|
|
||||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||||
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
||||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
"@aws-sdk/client-kms": "^3.1096.0",
|
"@aws-sdk/client-kms": "^3.1096.0",
|
||||||
"@aws-sdk/client-lambda": "^3.1076.0",
|
"@aws-sdk/client-lambda": "^3.1076.0",
|
||||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||||
"@aws-sdk/client-pipes": "^3.1146.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-route-53": "^3.1146.0",
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import {describe, expect, test} from 'bun:test'
|
|
||||||
import {
|
|
||||||
type ACMClient,
|
|
||||||
DeleteCertificateCommand,
|
|
||||||
DescribeCertificateCommand,
|
|
||||||
ExportCertificateCommand,
|
|
||||||
GetCertificateCommand,
|
|
||||||
ImportCertificateCommand,
|
|
||||||
ListCertificatesCommand,
|
|
||||||
RequestCertificateCommand,
|
|
||||||
RevokeCertificateCommand,
|
|
||||||
} from '@aws-sdk/client-acm'
|
|
||||||
import {AwsCertificatesAdapter} from './AwsCertificatesAdapter'
|
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
|
||||||
|
|
||||||
type SendResult = Record<string, unknown>
|
|
||||||
|
|
||||||
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
||||||
const sent: object[] = []
|
|
||||||
const client = {
|
|
||||||
async send(command: object) {
|
|
||||||
sent.push(command)
|
|
||||||
return handler(command)
|
|
||||||
},
|
|
||||||
} as unknown as ACMClient
|
|
||||||
return {client, sent}
|
|
||||||
}
|
|
||||||
|
|
||||||
function awsError(name: string, status = 400): Error {
|
|
||||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
|
||||||
const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}`
|
|
||||||
const detail = {
|
|
||||||
CertificateArn: ARN,
|
|
||||||
DomainName: 'notes.thecloud.localhost',
|
|
||||||
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'],
|
|
||||||
DomainValidationOptions: [{
|
|
||||||
DomainName: 'notes.thecloud.localhost',
|
|
||||||
ValidationStatus: 'SUCCESS',
|
|
||||||
ValidationMethod: 'DNS',
|
|
||||||
ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'},
|
|
||||||
}],
|
|
||||||
Status: 'ISSUED',
|
|
||||||
Type: 'AMAZON_ISSUED',
|
|
||||||
KeyAlgorithm: 'RSA-2048',
|
|
||||||
Issuer: 'CN=Floci Local CA',
|
|
||||||
CreatedAt: new Date('2026-10-25T16:28:12Z'),
|
|
||||||
NotAfter: new Date('2027-10-25T16:28:12Z'),
|
|
||||||
InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'],
|
|
||||||
Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'},
|
|
||||||
}
|
|
||||||
|
|
||||||
function adapterWith(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
||||||
const stub = stubAcm(handler)
|
|
||||||
return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AwsCertificatesAdapter', () => {
|
|
||||||
test('lists every key type across pages and searches the alternative names too', async () => {
|
|
||||||
const {adapter, sent} = adapterWith((command) => {
|
|
||||||
if (!(command instanceof ListCertificatesCommand)) return {}
|
|
||||||
return command.input.NextToken
|
|
||||||
? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]}
|
|
||||||
: {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'}
|
|
||||||
})
|
|
||||||
|
|
||||||
const all = await adapter.list()
|
|
||||||
expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([
|
|
||||||
[ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'],
|
|
||||||
['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'],
|
|
||||||
])
|
|
||||||
// Without Includes, AWS leaves EC certificates out of the list.
|
|
||||||
expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1')
|
|
||||||
expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('describes a certificate with its users and the DNS records validation asks for', async () => {
|
|
||||||
const {adapter, sent} = adapterWith(() => ({Certificate: detail}))
|
|
||||||
|
|
||||||
const certificate = await adapter.get(ID)
|
|
||||||
expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN)
|
|
||||||
expect(certificate?.metadata).toMatchObject({
|
|
||||||
inUse: 'Yes',
|
|
||||||
inUseBy: detail.InUseBy,
|
|
||||||
exportable: true,
|
|
||||||
issuer: 'CN=Floci Local CA',
|
|
||||||
notAfter: '2027-10-25T16:28:12.000Z',
|
|
||||||
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => {
|
|
||||||
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') })
|
|
||||||
expect(await adapter.get(ID)).toBeNull()
|
|
||||||
await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
expect(sent).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('requests a certificate for the domain and its extra names', async () => {
|
|
||||||
const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail})
|
|
||||||
|
|
||||||
const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}})
|
|
||||||
expect(created.id).toBe(ID)
|
|
||||||
expect((sent[0] as RequestCertificateCommand).input).toEqual({
|
|
||||||
DomainName: 'notes.thecloud.localhost',
|
|
||||||
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'],
|
|
||||||
ValidationMethod: 'DNS',
|
|
||||||
KeyAlgorithm: 'EC_prime256v1',
|
|
||||||
Options: {Export: 'ENABLED'},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refuses a request ACM would reject before calling it', async () => {
|
|
||||||
const {adapter, sent} = adapterWith(() => ({}))
|
|
||||||
await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name')
|
|
||||||
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
expect(sent).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('imports PEM text as bytes, with the chain when given', async () => {
|
|
||||||
const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}})
|
|
||||||
const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
|
|
||||||
const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----'
|
|
||||||
|
|
||||||
const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}})
|
|
||||||
expect(created.metadata.certificateType).toBe('Imported')
|
|
||||||
const input = (sent[0] as ImportCertificateCommand).input
|
|
||||||
expect(new TextDecoder().decode(input.Certificate)).toBe(certificate)
|
|
||||||
expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey)
|
|
||||||
expect(input.CertificateChain).toBeDefined()
|
|
||||||
|
|
||||||
await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('downloads, exports and revokes by the rebuilt ARN', async () => {
|
|
||||||
const {adapter, sent} = adapterWith((command) => {
|
|
||||||
if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'}
|
|
||||||
if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'}
|
|
||||||
if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN}
|
|
||||||
return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'})
|
|
||||||
expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'})
|
|
||||||
expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame')
|
|
||||||
const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED')
|
|
||||||
expect(revoked.status).toBe('REVOKED')
|
|
||||||
expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'})
|
|
||||||
|
|
||||||
await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => {
|
|
||||||
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') })
|
|
||||||
await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException')
|
|
||||||
expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import {
|
|
||||||
type ACMClient,
|
|
||||||
type CertificateDetail,
|
|
||||||
type CertificateSummary,
|
|
||||||
DeleteCertificateCommand,
|
|
||||||
DescribeCertificateCommand,
|
|
||||||
ExportCertificateCommand,
|
|
||||||
GetCertificateCommand,
|
|
||||||
ImportCertificateCommand,
|
|
||||||
type KeyAlgorithm,
|
|
||||||
ListCertificatesCommand,
|
|
||||||
RequestCertificateCommand,
|
|
||||||
RevokeCertificateCommand,
|
|
||||||
} from '@aws-sdk/client-acm'
|
|
||||||
import {
|
|
||||||
awsCertificatesSchema,
|
|
||||||
CERTIFICATE_DOMAIN_PATTERN,
|
|
||||||
CERTIFICATE_KEY_ALGORITHMS,
|
|
||||||
CERTIFICATE_REVOCATION_REASONS,
|
|
||||||
CERTIFICATE_VALIDATION_METHODS,
|
|
||||||
} from '../cloud-spi/certificatesSchema'
|
|
||||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
|
||||||
import type {
|
|
||||||
CertificatePem,
|
|
||||||
CertificateRevocationReason,
|
|
||||||
CloudResource,
|
|
||||||
CloudServiceAdapter,
|
|
||||||
CreateResourceInput,
|
|
||||||
ExportedCertificate,
|
|
||||||
ResourceQuery,
|
|
||||||
ServiceSchema,
|
|
||||||
} from '../cloud-spi/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ListCertificates returns only RSA certificates unless asked for other key
|
|
||||||
* types, so a list without this would silently leave out every EC certificate.
|
|
||||||
*/
|
|
||||||
const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1']
|
|
||||||
/** ACM's limit on names per certificate, the domain name included. */
|
|
||||||
const MAX_NAMES = 100
|
|
||||||
const CERTIFICATE_TYPE_LABEL: Record<string, string> = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Talks to AWS Certificate Manager, verified against Floci core (community, `latest`).
|
|
||||||
*
|
|
||||||
* Notes from probing the runtime:
|
|
||||||
* - Requested certificates are signed by the runtime's own local CA and issued at
|
|
||||||
* once (its validation wait defaults to 0), but DescribeCertificate still lists
|
|
||||||
* the CNAME records DNS validation would ask for, as AWS does.
|
|
||||||
* - DeleteCertificate refuses with ResourceInUseException while a load balancer,
|
|
||||||
* distribution or API domain uses the certificate, as on AWS.
|
|
||||||
*
|
|
||||||
* Resources are keyed by the certificate's UUID, the last part of its ARN, because
|
|
||||||
* an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the
|
|
||||||
* account and region this adapter serves.
|
|
||||||
*/
|
|
||||||
export class AwsCertificatesAdapter implements CloudServiceAdapter {
|
|
||||||
readonly cloud = 'aws' as const
|
|
||||||
readonly service = 'certificates' as const
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly acm: ACMClient,
|
|
||||||
private readonly accountId: string,
|
|
||||||
private readonly region = process.env.AWS_REGION || 'us-east-1',
|
|
||||||
) {}
|
|
||||||
|
|
||||||
schema(): ServiceSchema {
|
|
||||||
return awsCertificatesSchema()
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
|
||||||
const summaries: CertificateSummary[] = []
|
|
||||||
let next: string | undefined
|
|
||||||
do {
|
|
||||||
const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})}))
|
|
||||||
summaries.push(...(res.CertificateSummaryList ?? []))
|
|
||||||
next = res.NextToken
|
|
||||||
} while (next)
|
|
||||||
|
|
||||||
const search = query.search?.trim().toLowerCase()
|
|
||||||
return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search
|
|
||||||
|| certificate.name.toLowerCase().includes(search)
|
|
||||||
|| certificate.id.includes(search)
|
|
||||||
|| (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(id: string): Promise<CloudResource | null> {
|
|
||||||
try {
|
|
||||||
const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)}))
|
|
||||||
return res.Certificate ? this.fromDetail(res.Certificate) : null
|
|
||||||
} catch (error) {
|
|
||||||
if (errorName(error) === 'ResourceNotFoundException') return null
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
|
||||||
const source = input.values.source ?? 'request'
|
|
||||||
const arn = source === 'import' ? await this.importCertificate(input.values)
|
|
||||||
: source === 'request' ? await this.requestCertificate(input.values)
|
|
||||||
: null
|
|
||||||
if (arn === null) throw new ValidationError('source must be request or import')
|
|
||||||
|
|
||||||
const created = await this.get(idFromArn(arn))
|
|
||||||
if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`)
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCertificatePem(id: string): Promise<CertificatePem> {
|
|
||||||
const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)}))
|
|
||||||
if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate')
|
|
||||||
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AWS exports only private certificates and public ones requested as
|
|
||||||
* exportable; the private key comes back encrypted with the passphrase.
|
|
||||||
*/
|
|
||||||
async exportCertificate(id: string, passphrase: string): Promise<ExportedCertificate> {
|
|
||||||
if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) {
|
|
||||||
throw new ValidationError('passphrase must be 4 to 128 characters')
|
|
||||||
}
|
|
||||||
if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %')
|
|
||||||
|
|
||||||
const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)}))
|
|
||||||
if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate')
|
|
||||||
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey}
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise<CloudResource> {
|
|
||||||
if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) {
|
|
||||||
throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`)
|
|
||||||
}
|
|
||||||
await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason}))
|
|
||||||
const revoked = await this.get(id)
|
|
||||||
if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`)
|
|
||||||
return revoked
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestCertificate(values: Record<string, unknown>): Promise<string> {
|
|
||||||
const domainName = text(values.domainName).toLowerCase()
|
|
||||||
if (!domainName) throw new ValidationError('domainName is required')
|
|
||||||
const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN)
|
|
||||||
if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com')
|
|
||||||
|
|
||||||
const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))]
|
|
||||||
const invalid = alternatives.find((name) => !pattern.test(name))
|
|
||||||
if (invalid) throw new ValidationError(`${invalid} is not a domain name`)
|
|
||||||
if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`)
|
|
||||||
|
|
||||||
const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod')
|
|
||||||
const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm')
|
|
||||||
const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export')
|
|
||||||
|
|
||||||
const res = await this.acm.send(new RequestCertificateCommand({
|
|
||||||
DomainName: domainName,
|
|
||||||
...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}),
|
|
||||||
ValidationMethod: validationMethod,
|
|
||||||
KeyAlgorithm: keyAlgorithm,
|
|
||||||
Options: {Export: exportOption},
|
|
||||||
}))
|
|
||||||
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate')
|
|
||||||
return res.CertificateArn
|
|
||||||
}
|
|
||||||
|
|
||||||
private async importCertificate(values: Record<string, unknown>): Promise<string> {
|
|
||||||
const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE')
|
|
||||||
const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY')
|
|
||||||
const chain = text(values.certificateChain)
|
|
||||||
if (chain) pem(chain, 'certificateChain', 'CERTIFICATE')
|
|
||||||
|
|
||||||
const encode = (value: string) => new TextEncoder().encode(value)
|
|
||||||
const res = await this.acm.send(new ImportCertificateCommand({
|
|
||||||
Certificate: encode(certificate),
|
|
||||||
PrivateKey: encode(privateKey),
|
|
||||||
...(chain ? {CertificateChain: encode(chain)} : {}),
|
|
||||||
}))
|
|
||||||
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate')
|
|
||||||
return res.CertificateArn
|
|
||||||
}
|
|
||||||
|
|
||||||
private arn(id: string): string {
|
|
||||||
if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN')
|
|
||||||
return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
private fromSummary(summary: CertificateSummary): CloudResource {
|
|
||||||
const arn = summary.CertificateArn ?? ''
|
|
||||||
return {
|
|
||||||
id: idFromArn(arn),
|
|
||||||
name: summary.DomainName ?? '',
|
|
||||||
cloud: 'aws',
|
|
||||||
service: 'certificates',
|
|
||||||
type: 'certificate',
|
|
||||||
region: arn.split(':')[3] || this.region,
|
|
||||||
createdAt: iso(summary.CreatedAt ?? summary.ImportedAt),
|
|
||||||
status: summary.Status ?? null,
|
|
||||||
metadata: {
|
|
||||||
arn,
|
|
||||||
certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null,
|
|
||||||
inUse: summary.InUse ? 'Yes' : 'No',
|
|
||||||
keyAlgorithm: summary.KeyAlgorithm ?? null,
|
|
||||||
notAfter: iso(summary.NotAfter),
|
|
||||||
subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fromDetail(detail: CertificateDetail): CloudResource {
|
|
||||||
const arn = detail.CertificateArn ?? ''
|
|
||||||
const inUseBy = detail.InUseBy ?? []
|
|
||||||
return {
|
|
||||||
id: idFromArn(arn),
|
|
||||||
name: detail.DomainName ?? '',
|
|
||||||
cloud: 'aws',
|
|
||||||
service: 'certificates',
|
|
||||||
type: 'certificate',
|
|
||||||
region: arn.split(':')[3] || this.region,
|
|
||||||
createdAt: iso(detail.CreatedAt ?? detail.ImportedAt),
|
|
||||||
status: detail.Status ?? null,
|
|
||||||
metadata: {
|
|
||||||
arn,
|
|
||||||
certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null,
|
|
||||||
inUse: inUseBy.length > 0 ? 'Yes' : 'No',
|
|
||||||
inUseBy,
|
|
||||||
keyAlgorithm: detail.KeyAlgorithm ?? null,
|
|
||||||
signatureAlgorithm: detail.SignatureAlgorithm ?? null,
|
|
||||||
notBefore: iso(detail.NotBefore),
|
|
||||||
notAfter: iso(detail.NotAfter),
|
|
||||||
issuedAt: iso(detail.IssuedAt),
|
|
||||||
importedAt: iso(detail.ImportedAt),
|
|
||||||
revokedAt: iso(detail.RevokedAt),
|
|
||||||
revocationReason: detail.RevocationReason ?? null,
|
|
||||||
failureReason: detail.FailureReason ?? null,
|
|
||||||
subject: detail.Subject ?? null,
|
|
||||||
issuer: detail.Issuer ?? null,
|
|
||||||
serial: detail.Serial ?? null,
|
|
||||||
subjectAlternativeNames: detail.SubjectAlternativeNames ?? [],
|
|
||||||
exportable: detail.Options?.Export === 'ENABLED',
|
|
||||||
transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null,
|
|
||||||
renewalEligibility: detail.RenewalEligibility ?? null,
|
|
||||||
domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({
|
|
||||||
domainName: validation.DomainName ?? '',
|
|
||||||
status: validation.ValidationStatus ?? null,
|
|
||||||
method: validation.ValidationMethod ?? null,
|
|
||||||
record: validation.ResourceRecord
|
|
||||||
? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''}
|
|
||||||
: null,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `arn:aws:acm:us-east-1:000000000000:certificate/<uuid>` — the UUID is what the console keys on. */
|
|
||||||
function idFromArn(arn: string): string {
|
|
||||||
return arn.slice(arn.lastIndexOf('/') + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function iso(value: Date | undefined): string | null {
|
|
||||||
return value ? value.toISOString() : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function pem(value: unknown, field: string, label: string): string {
|
|
||||||
const body = text(value)
|
|
||||||
if (!body) throw new ValidationError(`${field} is required`)
|
|
||||||
if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) {
|
|
||||||
throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`)
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
function oneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] {
|
|
||||||
const raw = text(value)
|
|
||||||
if (!raw) return allowed[0] as T[number]
|
|
||||||
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
|
||||||
return raw as T[number]
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorName(error: unknown): string | undefined {
|
|
||||||
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import {describe, expect, test} from 'bun:test'
|
|
||||||
import {
|
|
||||||
CreatePipeCommand,
|
|
||||||
DeletePipeCommand,
|
|
||||||
DescribePipeCommand,
|
|
||||||
ListPipesCommand,
|
|
||||||
type PipesClient,
|
|
||||||
StartPipeCommand,
|
|
||||||
StopPipeCommand,
|
|
||||||
} from '@aws-sdk/client-pipes'
|
|
||||||
import {AwsPipesAdapter, describeArn} from './AwsPipesAdapter'
|
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
|
||||||
|
|
||||||
type SendResult = Record<string, unknown>
|
|
||||||
|
|
||||||
function stubPipes(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
||||||
const sent: object[] = []
|
|
||||||
const client = {
|
|
||||||
async send(command: object) {
|
|
||||||
sent.push(command)
|
|
||||||
return handler(command)
|
|
||||||
},
|
|
||||||
} as unknown as PipesClient
|
|
||||||
return {client, sent, adapter: new AwsPipesAdapter(client)}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SRC = 'arn:aws:sqs:us-east-1:000000000000:pipe-src'
|
|
||||||
const DST = 'arn:aws:lambda:us-east-1:000000000000:function:worker'
|
|
||||||
const ROLE = 'arn:aws:iam::000000000000:role/pipe-role'
|
|
||||||
const described = {
|
|
||||||
Name: 'orders-pipe', Arn: 'arn:aws:pipes:us-east-1:000000000000:pipe/orders-pipe',
|
|
||||||
DesiredState: 'RUNNING', CurrentState: 'RUNNING', Source: SRC, Target: DST, RoleArn: ROLE,
|
|
||||||
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
|
|
||||||
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
|
|
||||||
CreationTime: new Date('2026-10-25T16:42:45Z'),
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AwsPipesAdapter', () => {
|
|
||||||
test('lists pipes across pages with readable source and target names', async () => {
|
|
||||||
const {adapter} = stubPipes((command) => {
|
|
||||||
if (!(command instanceof ListPipesCommand)) return {}
|
|
||||||
return command.input.NextToken
|
|
||||||
? {Pipes: [{Name: 'clicks', CurrentState: 'STOPPED', Source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', Target: 'arn:aws:events:us-east-1:000000000000:event-bus/default'}]}
|
|
||||||
: {Pipes: [{Name: 'orders-pipe', CurrentState: 'RUNNING', Source: SRC, Target: DST}], NextToken: 'n'}
|
|
||||||
})
|
|
||||||
|
|
||||||
const pipes = await adapter.list()
|
|
||||||
expect(pipes.map((pipe) => [pipe.name, pipe.status, pipe.metadata.sourceName, pipe.metadata.targetName])).toEqual([
|
|
||||||
['orders-pipe', 'RUNNING', 'pipe-src (SQS queue)', 'worker (Lambda function)'],
|
|
||||||
['clicks', 'STOPPED', 'clicks (Kinesis stream)', 'default (EventBridge event bus)'],
|
|
||||||
])
|
|
||||||
expect((await adapter.list({search: 'kinesis'})).map((pipe) => pipe.name)).toEqual(['clicks'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('describes the filter, batch size, template and role', async () => {
|
|
||||||
const {adapter} = stubPipes(() => described)
|
|
||||||
expect((await adapter.get('orders-pipe'))?.metadata).toMatchObject({
|
|
||||||
filterPatterns: ['{"body":{"kind":["order"]}}'],
|
|
||||||
batchSize: 5,
|
|
||||||
inputTemplate: '{"id": <$.messageId>}',
|
|
||||||
roleArn: ROLE,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a missing pipe is null', async () => {
|
|
||||||
const {adapter} = stubPipes(() => { throw Object.assign(new Error('gone'), {name: 'NotFoundException'}) })
|
|
||||||
expect(await adapter.get('nope')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('creates an SQS pipe with its filter, batch size and template', async () => {
|
|
||||||
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
|
|
||||||
|
|
||||||
await adapter.create({values: {name: 'orders-pipe', source: SRC, target: DST, roleArn: ROLE, batchSize: '5', filterPattern: '{ "body": { "kind": ["order"] } }', inputTemplate: '{"id": <$.messageId>}'}})
|
|
||||||
expect((sent[0] as CreatePipeCommand).input).toEqual({
|
|
||||||
Name: 'orders-pipe',
|
|
||||||
Source: SRC,
|
|
||||||
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
|
|
||||||
Target: DST,
|
|
||||||
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
|
|
||||||
RoleArn: ROLE,
|
|
||||||
DesiredState: 'RUNNING',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('gives stream and MSK sources the starting position they require', async () => {
|
|
||||||
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
|
|
||||||
|
|
||||||
await adapter.create({values: {name: 'clicks', source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', target: DST, roleArn: ROLE, startingPosition: 'TRIM_HORIZON', desiredState: 'STOPPED'}})
|
|
||||||
expect((sent[0] as CreatePipeCommand).input).toMatchObject({SourceParameters: {KinesisStreamParameters: {StartingPosition: 'TRIM_HORIZON'}}, DesiredState: 'STOPPED'})
|
|
||||||
|
|
||||||
await adapter.create({values: {name: 'k', source: 'arn:aws:kafka:us-east-1:000000000000:cluster/events/abc', target: DST, roleArn: ROLE, topicName: 'orders'}})
|
|
||||||
expect((sent[2] as CreatePipeCommand).input.SourceParameters).toEqual({ManagedStreamingKafkaParameters: {TopicName: 'orders', StartingPosition: 'LATEST'}})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refuses pipes the runtime could not run, before calling it', async () => {
|
|
||||||
const {adapter, sent} = stubPipes(() => ({}))
|
|
||||||
const base = {name: 'p', source: SRC, target: DST, roleArn: ROLE}
|
|
||||||
await expect(adapter.create({values: {...base, target: 'arn:aws:s3:::bucket'}})).rejects.toThrow('target must be the ARN of a')
|
|
||||||
await expect(adapter.create({values: {...base, source: 'arn:aws:sns:us-east-1:000000000000:topic'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.create({values: {...base, enrichment: 'arn:aws:sqs:us-east-1:000000000000:q'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.create({values: {...base, filterPattern: '{kind: order}'}})).rejects.toThrow('filterPattern must be JSON')
|
|
||||||
await expect(adapter.create({values: {...base, batchSize: '20000'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
await expect(adapter.create({values: {...base, source: 'arn:aws:kafka:us-east-1:000000000000:cluster/c/1'}})).rejects.toThrow('topicName is required')
|
|
||||||
await expect(adapter.create({values: {...base, roleArn: 'pipe-role'}})).rejects.toBeInstanceOf(ValidationError)
|
|
||||||
expect(sent).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('starts, stops and deletes by name', async () => {
|
|
||||||
const {adapter, sent} = stubPipes(() => ({}))
|
|
||||||
await adapter.stop('orders-pipe')
|
|
||||||
await adapter.start('orders-pipe')
|
|
||||||
await adapter.delete('orders-pipe')
|
|
||||||
expect(sent.map((command) => command.constructor)).toEqual([StopPipeCommand, StartPipeCommand, DeletePipeCommand])
|
|
||||||
expect(sent.every((command) => (command as StopPipeCommand).input.Name === 'orders-pipe')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('names every kind of source and target ARN', () => {
|
|
||||||
expect(describeArn('arn:aws:dynamodb:us-east-1:000000000000:table/orders/stream/2026-10-25T00:00:00.000')).toBe('orders (DynamoDB stream)')
|
|
||||||
expect(describeArn('arn:aws:states:us-east-1:000000000000:stateMachine:flow')).toBe('flow (Step Functions state machine)')
|
|
||||||
expect(describeArn('arn:aws:sns:us-east-1:000000000000:alerts')).toBe('alerts (SNS topic)')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import {
|
|
||||||
CreatePipeCommand,
|
|
||||||
DeletePipeCommand,
|
|
||||||
DescribePipeCommand,
|
|
||||||
type DescribePipeCommandOutput,
|
|
||||||
ListPipesCommand,
|
|
||||||
type Pipe,
|
|
||||||
type PipeSourceParameters,
|
|
||||||
type PipesClient,
|
|
||||||
StartPipeCommand,
|
|
||||||
StopPipeCommand,
|
|
||||||
} from '@aws-sdk/client-pipes'
|
|
||||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
|
||||||
import {
|
|
||||||
awsPipesSchema,
|
|
||||||
PIPE_ENRICHMENT_SERVICES,
|
|
||||||
PIPE_NAME_PATTERN,
|
|
||||||
PIPE_SOURCE_SERVICES,
|
|
||||||
PIPE_STARTING_POSITIONS,
|
|
||||||
PIPE_TARGET_SERVICES,
|
|
||||||
} from '../cloud-spi/pipesSchema'
|
|
||||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
|
||||||
|
|
||||||
const MAX_BATCH_SIZE = 10_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Talks to EventBridge Pipes, verified against Floci core (community, `latest`).
|
|
||||||
*
|
|
||||||
* Notes from probing the runtime:
|
|
||||||
* - Pipes really run: the runtime polls SQS, Kinesis, DynamoDB streams and MSK,
|
|
||||||
* applies the filter, enrichment and input template, and calls the target.
|
|
||||||
* - Its poller delivers only to Lambda, SQS, SNS, EventBridge buses and Step
|
|
||||||
* Functions, so create refuses other targets instead of making a pipe that
|
|
||||||
* would never deliver.
|
|
||||||
* - ListPipes returns no parameters, so the table shows only what it carries;
|
|
||||||
* the filter, template and role come from DescribePipe.
|
|
||||||
*/
|
|
||||||
export class AwsPipesAdapter implements CloudServiceAdapter {
|
|
||||||
readonly cloud = 'aws' as const
|
|
||||||
readonly service = 'pipes' as const
|
|
||||||
|
|
||||||
constructor(private readonly pipes: PipesClient) {}
|
|
||||||
|
|
||||||
schema(): ServiceSchema {
|
|
||||||
return awsPipesSchema()
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
|
||||||
const found: Pipe[] = []
|
|
||||||
let next: string | undefined
|
|
||||||
do {
|
|
||||||
const res = await this.pipes.send(new ListPipesCommand({Limit: 100, ...(next ? {NextToken: next} : {})}))
|
|
||||||
found.push(...(res.Pipes ?? []))
|
|
||||||
next = res.NextToken
|
|
||||||
} while (next)
|
|
||||||
|
|
||||||
const search = query.search?.trim().toLowerCase()
|
|
||||||
return found.map((pipe) => toResource(pipe)).filter((pipe) => !search
|
|
||||||
|| pipe.name.toLowerCase().includes(search)
|
|
||||||
|| String(pipe.metadata.source).toLowerCase().includes(search)
|
|
||||||
|| String(pipe.metadata.target).toLowerCase().includes(search))
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(id: string): Promise<CloudResource | null> {
|
|
||||||
try {
|
|
||||||
return toResource(await this.pipes.send(new DescribePipeCommand({Name: id})))
|
|
||||||
} catch (error) {
|
|
||||||
if (errorName(error) === 'NotFoundException') return null
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
|
||||||
const values = input.values
|
|
||||||
const name = text(values.name)
|
|
||||||
if (!new RegExp(PIPE_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 64 letters, numbers, dots, dashes or underscores')
|
|
||||||
|
|
||||||
const source = text(values.source)
|
|
||||||
const sourceService = supported(source, PIPE_SOURCE_SERVICES, 'source')
|
|
||||||
const target = text(values.target)
|
|
||||||
supported(target, PIPE_TARGET_SERVICES, 'target')
|
|
||||||
const enrichment = text(values.enrichment)
|
|
||||||
if (enrichment) supported(enrichment, PIPE_ENRICHMENT_SERVICES, 'enrichment')
|
|
||||||
const roleArn = text(values.roleArn)
|
|
||||||
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) throw new ValidationError('roleArn must be an IAM role ARN')
|
|
||||||
|
|
||||||
const inputTemplate = text(values.inputTemplate)
|
|
||||||
const description = text(values.description)
|
|
||||||
const desiredState = values.desiredState === 'STOPPED' ? 'STOPPED' : 'RUNNING'
|
|
||||||
|
|
||||||
await this.pipes.send(new CreatePipeCommand({
|
|
||||||
Name: name,
|
|
||||||
Source: source,
|
|
||||||
SourceParameters: sourceParameters(sourceService, values),
|
|
||||||
Target: target,
|
|
||||||
...(inputTemplate ? {TargetParameters: {InputTemplate: inputTemplate}} : {}),
|
|
||||||
...(enrichment ? {Enrichment: enrichment} : {}),
|
|
||||||
RoleArn: roleArn,
|
|
||||||
DesiredState: desiredState,
|
|
||||||
...(description ? {Description: description} : {}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const created = await this.get(name)
|
|
||||||
if (!created) throw new RuntimeError(`EventBridge Pipes did not return the new pipe ${name}`)
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await this.pipes.send(new DeletePipeCommand({Name: id}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(id: string): Promise<void> {
|
|
||||||
await this.pipes.send(new StartPipeCommand({Name: id}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(id: string): Promise<void> {
|
|
||||||
await this.pipes.send(new StopPipeCommand({Name: id}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Each source kind carries its own parameter block, as CreatePipe expects. */
|
|
||||||
function sourceParameters(service: keyof typeof PIPE_SOURCE_SERVICES, values: Record<string, unknown>): PipeSourceParameters {
|
|
||||||
const batchSize = optionalBatchSize(values.batchSize)
|
|
||||||
const filterPattern = text(values.filterPattern)
|
|
||||||
const parameters: PipeSourceParameters = filterPattern ? {FilterCriteria: {Filters: [{Pattern: jsonObject(filterPattern, 'filterPattern')}]}} : {}
|
|
||||||
const batch = batchSize ? {BatchSize: batchSize} : {}
|
|
||||||
|
|
||||||
if (service === 'sqs') return {...parameters, SqsQueueParameters: batch}
|
|
||||||
|
|
||||||
const startingPosition = text(values.startingPosition) || 'LATEST'
|
|
||||||
if (!(PIPE_STARTING_POSITIONS as readonly string[]).includes(startingPosition)) {
|
|
||||||
throw new ValidationError(`startingPosition must be one of ${PIPE_STARTING_POSITIONS.join(', ')}`)
|
|
||||||
}
|
|
||||||
const position = startingPosition as (typeof PIPE_STARTING_POSITIONS)[number]
|
|
||||||
if (service === 'kinesis') return {...parameters, KinesisStreamParameters: {StartingPosition: position, ...batch}}
|
|
||||||
if (service === 'dynamodb') return {...parameters, DynamoDBStreamParameters: {StartingPosition: position, ...batch}}
|
|
||||||
|
|
||||||
const topicName = text(values.topicName)
|
|
||||||
if (!topicName) throw new ValidationError('topicName is required for an MSK source')
|
|
||||||
return {...parameters, ManagedStreamingKafkaParameters: {TopicName: topicName, StartingPosition: position, ...batch}}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toResource(pipe: Pipe | DescribePipeCommandOutput): CloudResource {
|
|
||||||
const name = pipe.Name ?? ''
|
|
||||||
const detail = 'SourceParameters' in pipe ? pipe : undefined
|
|
||||||
const sourceParameters = detail?.SourceParameters
|
|
||||||
const batchSize = sourceParameters?.SqsQueueParameters?.BatchSize
|
|
||||||
?? sourceParameters?.KinesisStreamParameters?.BatchSize
|
|
||||||
?? sourceParameters?.DynamoDBStreamParameters?.BatchSize
|
|
||||||
?? sourceParameters?.ManagedStreamingKafkaParameters?.BatchSize
|
|
||||||
return {
|
|
||||||
id: name,
|
|
||||||
name,
|
|
||||||
cloud: 'aws',
|
|
||||||
service: 'pipes',
|
|
||||||
type: 'pipe',
|
|
||||||
region: pipe.Arn?.split(':')[3] || null,
|
|
||||||
createdAt: pipe.CreationTime ? pipe.CreationTime.toISOString() : null,
|
|
||||||
status: pipe.CurrentState ?? null,
|
|
||||||
metadata: {
|
|
||||||
arn: pipe.Arn,
|
|
||||||
desiredState: pipe.DesiredState ?? null,
|
|
||||||
currentState: pipe.CurrentState ?? null,
|
|
||||||
stateReason: pipe.StateReason ?? null,
|
|
||||||
source: pipe.Source ?? null,
|
|
||||||
sourceName: describeArn(pipe.Source),
|
|
||||||
target: pipe.Target ?? null,
|
|
||||||
targetName: describeArn(pipe.Target),
|
|
||||||
enrichment: pipe.Enrichment ?? null,
|
|
||||||
lastModified: pipe.LastModifiedTime ? pipe.LastModifiedTime.toISOString() : null,
|
|
||||||
...(detail ? {
|
|
||||||
description: detail.Description ?? null,
|
|
||||||
roleArn: detail.RoleArn ?? null,
|
|
||||||
filterPatterns: (sourceParameters?.FilterCriteria?.Filters ?? []).map((filter) => filter.Pattern ?? '').filter(Boolean),
|
|
||||||
batchSize: batchSize ?? null,
|
|
||||||
startingPosition: sourceParameters?.KinesisStreamParameters?.StartingPosition
|
|
||||||
?? sourceParameters?.DynamoDBStreamParameters?.StartingPosition
|
|
||||||
?? sourceParameters?.ManagedStreamingKafkaParameters?.StartingPosition
|
|
||||||
?? null,
|
|
||||||
topicName: sourceParameters?.ManagedStreamingKafkaParameters?.TopicName ?? null,
|
|
||||||
inputTemplate: detail.TargetParameters?.InputTemplate ?? null,
|
|
||||||
enrichmentInputTemplate: detail.EnrichmentParameters?.InputTemplate ?? null,
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A readable name for an ARN: `queue (SQS queue)`. The resource part differs by
|
|
||||||
* service — `function:name`, `stream/name`, `table/name/stream/…`, `cluster/name/uuid`.
|
|
||||||
*/
|
|
||||||
export function describeArn(arn: string | undefined): string | null {
|
|
||||||
if (!arn) return null
|
|
||||||
const parts = arn.split(':')
|
|
||||||
const service = parts[2] ?? ''
|
|
||||||
const resource = parts.slice(5).join(':')
|
|
||||||
const name = service === 'lambda' || service === 'states' ? resource.split(':')[1] ?? resource
|
|
||||||
: service === 'dynamodb' || service === 'kafka' || service === 'kinesis' || service === 'events' ? resource.split('/')[1] ?? resource
|
|
||||||
: resource
|
|
||||||
const kind = (PIPE_SOURCE_SERVICES as Record<string, string>)[service] ?? (PIPE_TARGET_SERVICES as Record<string, string>)[service]
|
|
||||||
return kind ? `${name} (${kind})` : name
|
|
||||||
}
|
|
||||||
|
|
||||||
function supported<T extends Record<string, string>>(arn: string, services: T, field: string): keyof T {
|
|
||||||
const service = arn.split(':')[2] ?? ''
|
|
||||||
if (!arn.startsWith('arn:') || !(service in services)) {
|
|
||||||
throw new ValidationError(`${field} must be the ARN of a ${Object.values(services).join(', ')}`)
|
|
||||||
}
|
|
||||||
return service as keyof T
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalBatchSize(value: unknown): number | undefined {
|
|
||||||
const raw = text(value)
|
|
||||||
if (!raw) return undefined
|
|
||||||
const size = Number(raw)
|
|
||||||
if (!Number.isInteger(size) || size < 1 || size > MAX_BATCH_SIZE) throw new ValidationError(`batchSize must be a whole number from 1 to ${MAX_BATCH_SIZE}`)
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonObject(value: string, field: string): string {
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(value)
|
|
||||||
} catch {
|
|
||||||
throw new ValidationError(`${field} must be JSON, such as {"body": {"kind": ["order"]}}`)
|
|
||||||
}
|
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new ValidationError(`${field} must be a JSON object`)
|
|
||||||
return JSON.stringify(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorName(error: unknown): string | undefined {
|
|
||||||
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,6 @@ import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-
|
|||||||
import { Route53Client } from "@aws-sdk/client-route-53";
|
import { Route53Client } from "@aws-sdk/client-route-53";
|
||||||
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
||||||
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
|
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
|
||||||
import { ACMClient } from "@aws-sdk/client-acm";
|
|
||||||
import { PipesClient } from "@aws-sdk/client-pipes";
|
|
||||||
|
|
||||||
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";
|
||||||
@@ -103,8 +101,6 @@ export type AwsClients = {
|
|||||||
route53: Route53Client;
|
route53: Route53Client;
|
||||||
cloudfront: CloudFrontClient;
|
cloudfront: CloudFrontClient;
|
||||||
autoscaling: AutoScalingClient;
|
autoscaling: AutoScalingClient;
|
||||||
acm: ACMClient;
|
|
||||||
pipes: PipesClient;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AwsClientName = keyof AwsClients;
|
export type AwsClientName = keyof AwsClients;
|
||||||
@@ -155,8 +151,6 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
route53: new Route53Client(base),
|
route53: new Route53Client(base),
|
||||||
cloudfront: new CloudFrontClient(base),
|
cloudfront: new CloudFrontClient(base),
|
||||||
autoscaling: new AutoScalingClient(base),
|
autoscaling: new AutoScalingClient(base),
|
||||||
acm: new ACMClient(base),
|
|
||||||
pipes: new PipesClient(base),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
|
||||||
|
|
||||||
/** A domain name, optionally a wildcard (`*.example.com`), as RequestCertificate accepts it. */
|
|
||||||
export const CERTIFICATE_DOMAIN_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 key algorithms RequestCertificate accepts for a public certificate. */
|
|
||||||
export const CERTIFICATE_KEY_ALGORITHMS = ['RSA_2048', 'EC_prime256v1', 'EC_secp384r1'] as const
|
|
||||||
export const CERTIFICATE_VALIDATION_METHODS = ['DNS', 'EMAIL'] as const
|
|
||||||
/** RevokeCertificate's reasons, as the API names them. */
|
|
||||||
export const CERTIFICATE_REVOCATION_REASONS = [
|
|
||||||
'UNSPECIFIED',
|
|
||||||
'KEY_COMPROMISE',
|
|
||||||
'CA_COMPROMISE',
|
|
||||||
'AFFILIATION_CHANGED',
|
|
||||||
'SUPERSEDED',
|
|
||||||
'CESSATION_OF_OPERATION',
|
|
||||||
'PRIVILEGE_WITHDRAWN',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const certificateColumns: TableColumnSchema[] = [
|
|
||||||
{name: 'name', label: 'Domain name'},
|
|
||||||
{name: 'status', label: 'Status', format: 'badge'},
|
|
||||||
{name: 'certificateType', label: 'Type', path: 'metadata.certificateType'},
|
|
||||||
{name: 'inUse', label: 'In use', path: 'metadata.inUse'},
|
|
||||||
{name: 'keyAlgorithm', label: 'Key algorithm', path: 'metadata.keyAlgorithm'},
|
|
||||||
{name: 'notAfter', label: 'Expires', path: 'metadata.notAfter', format: 'datetime', emptyText: '—'},
|
|
||||||
{name: 'id', label: 'Certificate ID', format: 'code'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const certificateFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
|
||||||
|
|
||||||
const requested = {field: 'source', values: ['request']}
|
|
||||||
const imported = {field: 'source', values: ['import']}
|
|
||||||
|
|
||||||
export function awsCertificatesSchema(): ServiceSchema {
|
|
||||||
return {
|
|
||||||
cloud: 'aws',
|
|
||||||
service: 'certificates',
|
|
||||||
displayName: 'AWS Certificate Manager',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'source',
|
|
||||||
label: 'Certificate type',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
defaultValue: 'request',
|
|
||||||
options: [
|
|
||||||
{label: 'Request a public certificate', value: 'request'},
|
|
||||||
{label: 'Import a certificate', value: 'import'},
|
|
||||||
],
|
|
||||||
span: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'domainName',
|
|
||||||
label: 'Domain name',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
requiredWhen: {field: 'source', equals: 'request'},
|
|
||||||
visibleWhen: requested,
|
|
||||||
description: 'For example notes.thecloud.localhost, or *.thecloud.localhost for every subdomain. TheCloud signs it with its own local certificate authority.',
|
|
||||||
validation: {pattern: CERTIFICATE_DOMAIN_PATTERN, message: 'Use a domain name such as example.com or *.example.com'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'subjectAlternativeNames',
|
|
||||||
label: 'Additional names',
|
|
||||||
type: 'textarea',
|
|
||||||
required: false,
|
|
||||||
visibleWhen: requested,
|
|
||||||
description: 'Other names the certificate covers, one per line.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'validationMethod',
|
|
||||||
label: 'Validation method',
|
|
||||||
type: 'select',
|
|
||||||
required: false,
|
|
||||||
defaultValue: 'DNS',
|
|
||||||
visibleWhen: requested,
|
|
||||||
description: 'How ACM proves you own the domain. TheCloud issues the certificate without checking, and still shows the DNS records AWS would ask for.',
|
|
||||||
options: CERTIFICATE_VALIDATION_METHODS.map((value) => ({label: value, value})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'keyAlgorithm',
|
|
||||||
label: 'Key algorithm',
|
|
||||||
type: 'select',
|
|
||||||
required: false,
|
|
||||||
defaultValue: 'RSA_2048',
|
|
||||||
visibleWhen: requested,
|
|
||||||
options: CERTIFICATE_KEY_ALGORITHMS.map((value) => ({label: value.replace('_', ' '), value})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'export',
|
|
||||||
label: 'Exportable',
|
|
||||||
type: 'select',
|
|
||||||
required: false,
|
|
||||||
defaultValue: 'DISABLED',
|
|
||||||
visibleWhen: requested,
|
|
||||||
description: 'An exportable certificate can be downloaded with its private key, to use on servers outside AWS. This is chosen once and cannot be changed later.',
|
|
||||||
options: [
|
|
||||||
{label: 'No', value: 'DISABLED'},
|
|
||||||
{label: 'Yes, allow export', value: 'ENABLED'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'certificate',
|
|
||||||
label: 'Certificate body (PEM)',
|
|
||||||
type: 'textarea',
|
|
||||||
required: false,
|
|
||||||
requiredWhen: {field: 'source', equals: 'import'},
|
|
||||||
visibleWhen: imported,
|
|
||||||
span: true,
|
|
||||||
description: 'Starts with -----BEGIN CERTIFICATE-----.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'privateKey',
|
|
||||||
label: 'Private key (PEM)',
|
|
||||||
type: 'textarea',
|
|
||||||
required: false,
|
|
||||||
requiredWhen: {field: 'source', equals: 'import'},
|
|
||||||
visibleWhen: imported,
|
|
||||||
span: true,
|
|
||||||
description: 'The unencrypted key that matches the certificate.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'certificateChain',
|
|
||||||
label: 'Certificate chain (PEM)',
|
|
||||||
type: 'textarea',
|
|
||||||
required: false,
|
|
||||||
visibleWhen: imported,
|
|
||||||
span: true,
|
|
||||||
description: 'The intermediate certificates, if the certificate is not self-signed.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
actions: ['list', 'create', 'delete', 'inspect'],
|
|
||||||
capabilities: {
|
|
||||||
resourceActions: [
|
|
||||||
{name: 'list', label: 'List certificates', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'create', label: 'Request or import a certificate', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'delete', label: 'Delete certificate', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'inspect', label: 'Inspect certificate', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
],
|
|
||||||
certificateActions: [
|
|
||||||
{name: 'download', label: 'Download the certificate and chain', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'export', label: 'Export with the private key', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'revoke', label: 'Revoke certificate', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
filters: certificateFilters,
|
|
||||||
columns: certificateColumns,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function certificatesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
|
||||||
return cloud === 'aws' ? awsCertificatesSchema() : null
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
|
||||||
|
|
||||||
/** CreatePipe's name rule. */
|
|
||||||
export const PIPE_NAME_PATTERN = '^[\\.\\-_A-Za-z0-9]{1,64}$'
|
|
||||||
export const PIPE_STARTING_POSITIONS = ['TRIM_HORIZON', 'LATEST'] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sources and targets the runtime's pipe poller actually moves events
|
|
||||||
* between, keyed by the ARN's service field. Anything else is refused on create
|
|
||||||
* rather than accepted into a pipe that would never deliver.
|
|
||||||
*/
|
|
||||||
export const PIPE_SOURCE_SERVICES = {sqs: 'SQS queue', kinesis: 'Kinesis stream', dynamodb: 'DynamoDB stream', kafka: 'MSK topic'} as const
|
|
||||||
export const PIPE_TARGET_SERVICES = {lambda: 'Lambda function', sqs: 'SQS queue', sns: 'SNS topic', events: 'EventBridge event bus', states: 'Step Functions state machine'} as const
|
|
||||||
export const PIPE_ENRICHMENT_SERVICES = {lambda: 'Lambda function', states: 'Step Functions state machine'} as const
|
|
||||||
|
|
||||||
const ARN = '^arn:aws[a-z-]*:[a-z0-9-]+:[a-z0-9-]*:\\d{12}:.+$'
|
|
||||||
|
|
||||||
const pipeColumns: TableColumnSchema[] = [
|
|
||||||
{name: 'name', label: 'Name'},
|
|
||||||
{name: 'status', label: 'State', format: 'badge'},
|
|
||||||
{name: 'source', label: 'Source', path: 'metadata.sourceName'},
|
|
||||||
{name: 'target', label: 'Target', path: 'metadata.targetName'},
|
|
||||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const pipeFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
|
||||||
|
|
||||||
export function awsPipesSchema(): ServiceSchema {
|
|
||||||
return {
|
|
||||||
cloud: 'aws',
|
|
||||||
service: 'pipes',
|
|
||||||
displayName: 'EventBridge Pipes',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
label: 'Name',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
validation: {pattern: PIPE_NAME_PATTERN, message: 'Use up to 64 letters, numbers, dots, dashes and underscores'},
|
|
||||||
},
|
|
||||||
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 512}},
|
|
||||||
{
|
|
||||||
name: 'source',
|
|
||||||
label: 'Source ARN',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
span: true,
|
|
||||||
description: 'Where events come from: an SQS queue, a Kinesis stream, a DynamoDB stream or an MSK cluster.',
|
|
||||||
validation: {pattern: ARN, message: 'Use the ARN of a queue or stream'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'startingPosition',
|
|
||||||
label: 'Starting position',
|
|
||||||
type: 'select',
|
|
||||||
required: false,
|
|
||||||
defaultValue: 'LATEST',
|
|
||||||
description: 'For streams and MSK: read new records only, or everything still in the stream.',
|
|
||||||
options: [
|
|
||||||
{label: 'New records only (LATEST)', value: 'LATEST'},
|
|
||||||
{label: 'Oldest record (TRIM_HORIZON)', value: 'TRIM_HORIZON'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'topicName',
|
|
||||||
label: 'MSK topic',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
description: 'Required when the source is an MSK cluster.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'batchSize',
|
|
||||||
label: 'Batch size',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
description: 'Records sent to the target at once. 1 to 10,000; leave empty for the default of 10.',
|
|
||||||
validation: {pattern: '^[1-9][0-9]{0,4}$', message: 'Use a whole number from 1 to 10000'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'filterPattern',
|
|
||||||
label: 'Filter pattern',
|
|
||||||
type: 'textarea',
|
|
||||||
required: false,
|
|
||||||
span: true,
|
|
||||||
description: 'Optional EventBridge event pattern; only matching records go on. For SQS, match the parsed message under body, for example {"body": {"kind": ["order"]}}.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'enrichment',
|
|
||||||
label: 'Enrichment ARN',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
span: true,
|
|
||||||
description: 'Optional Lambda function or Step Functions state machine that transforms each batch before the target gets it.',
|
|
||||||
validation: {pattern: ARN, message: 'Use the ARN of a Lambda function or state machine'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'target',
|
|
||||||
label: 'Target ARN',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
span: true,
|
|
||||||
description: 'Where events go: a Lambda function, SQS queue, SNS topic, EventBridge event bus or Step Functions state machine.',
|
|
||||||
validation: {pattern: ARN, message: 'Use the ARN of the target'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'inputTemplate',
|
|
||||||
label: 'Target input template',
|
|
||||||
type: 'textarea',
|
|
||||||
required: false,
|
|
||||||
span: true,
|
|
||||||
description: 'Optional. Reshape what the target receives, with <$.path> placeholders, for example {"id": <$.messageId>}.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'roleArn',
|
|
||||||
label: 'Execution role ARN',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
span: true,
|
|
||||||
description: 'The IAM role the pipe uses to read the source and call the target, as AWS requires. Create one in IAM first.',
|
|
||||||
validation: {pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$', message: 'Use an IAM role ARN such as arn:aws:iam::000000000000:role/pipe-role'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'desiredState',
|
|
||||||
label: 'State after creating',
|
|
||||||
type: 'select',
|
|
||||||
required: false,
|
|
||||||
defaultValue: 'RUNNING',
|
|
||||||
options: [
|
|
||||||
{label: 'Running', value: 'RUNNING'},
|
|
||||||
{label: 'Stopped', value: 'STOPPED'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
actions: ['list', 'create', 'delete', 'inspect'],
|
|
||||||
capabilities: {
|
|
||||||
resourceActions: [
|
|
||||||
{name: 'list', label: 'List pipes', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'create', label: 'Create pipe', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'delete', label: 'Delete pipe', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'inspect', label: 'Inspect pipe', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'start', label: 'Start pipe', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
{name: 'stop', label: 'Stop pipe', enabled: true, status: 'available', runtimeRequired: true},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
filters: pipeFilters,
|
|
||||||
columns: pipeColumns,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pipesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
|
||||||
return cloud === 'aws' ? awsPipesSchema() : null
|
|
||||||
}
|
|
||||||
@@ -246,13 +246,6 @@ 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'},
|
||||||
},
|
},
|
||||||
certificates: {
|
|
||||||
displayName: 'Certificates',
|
|
||||||
displayNameByCloud: {aws: 'Certificate Manager'},
|
|
||||||
iconKey: 'certificates',
|
|
||||||
group: 'Security',
|
|
||||||
order: 35,
|
|
||||||
},
|
|
||||||
cdn: {
|
cdn: {
|
||||||
displayName: 'CDN',
|
displayName: 'CDN',
|
||||||
displayNameByCloud: {aws: 'CloudFront'},
|
displayNameByCloud: {aws: 'CloudFront'},
|
||||||
@@ -288,13 +281,6 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Provisioning',
|
group: 'Provisioning',
|
||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
pipes: {
|
|
||||||
displayName: 'Pipes',
|
|
||||||
displayNameByCloud: {aws: 'EventBridge Pipes'},
|
|
||||||
iconKey: 'pipes',
|
|
||||||
group: 'Integration',
|
|
||||||
order: 25,
|
|
||||||
},
|
|
||||||
scheduler: {
|
scheduler: {
|
||||||
displayName: 'Cloud Scheduler',
|
displayName: 'Cloud Scheduler',
|
||||||
displayNameByCloud: {gcp: 'Cloud Scheduler'},
|
displayNameByCloud: {gcp: 'Cloud Scheduler'},
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ export type UserPoolActionName =
|
|||||||
| 'updateClientOAuth'
|
| 'updateClientOAuth'
|
||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
|
||||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||||
export type HttpApiActionName =
|
export type HttpApiActionName =
|
||||||
| 'describe'
|
| 'describe'
|
||||||
@@ -248,7 +247,6 @@ export interface ServiceSchema {
|
|||||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||||
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
||||||
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
|
||||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||||
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||||
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||||
@@ -519,26 +517,6 @@ export interface AlarmHistoryItem {
|
|||||||
summary: string
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A certificate and its chain as PEM text; never the private key. */
|
|
||||||
export interface CertificatePem {
|
|
||||||
certificate: string
|
|
||||||
certificateChain: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An exported certificate: the private key is PEM, encrypted with the caller's passphrase. */
|
|
||||||
export interface ExportedCertificate extends CertificatePem {
|
|
||||||
privateKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CertificateRevocationReason =
|
|
||||||
| 'UNSPECIFIED'
|
|
||||||
| 'KEY_COMPROMISE'
|
|
||||||
| 'CA_COMPROMISE'
|
|
||||||
| 'AFFILIATION_CHANGED'
|
|
||||||
| 'SUPERSEDED'
|
|
||||||
| 'CESSATION_OF_OPERATION'
|
|
||||||
| 'PRIVILEGE_WITHDRAWN'
|
|
||||||
|
|
||||||
/** Any of the three may be left out to keep its current value. */
|
/** Any of the three may be left out to keep its current value. */
|
||||||
export interface AutoScalingCapacityInput {
|
export interface AutoScalingCapacityInput {
|
||||||
minSize?: number
|
minSize?: number
|
||||||
@@ -1216,9 +1194,6 @@ export interface CloudServiceAdapter {
|
|||||||
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
|
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
|
||||||
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
||||||
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
||||||
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
|
||||||
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
|
|
||||||
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
|
|
||||||
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 {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, 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.
|
||||||
@@ -75,12 +75,6 @@ const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof Cloud
|
|||||||
createInvalidation: 'createDistributionInvalidation',
|
createInvalidation: 'createDistributionInvalidation',
|
||||||
}
|
}
|
||||||
|
|
||||||
const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudServiceAdapter> = {
|
|
||||||
download: 'getCertificatePem',
|
|
||||||
export: 'exportCertificate',
|
|
||||||
revoke: 'revokeCertificate',
|
|
||||||
}
|
|
||||||
|
|
||||||
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
|
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
|
||||||
setCapacity: 'setAutoScalingCapacity',
|
setCapacity: 'setAutoScalingCapacity',
|
||||||
terminateInstance: 'terminateAutoScalingInstance',
|
terminateInstance: 'terminateAutoScalingInstance',
|
||||||
@@ -199,15 +193,6 @@ describe('registered adapters honour their schema', () => {
|
|||||||
).toBe('function')
|
).toBe('function')
|
||||||
}
|
}
|
||||||
|
|
||||||
const certificateClaims = (adapter.schema().capabilities?.certificateActions ?? []).filter((capability) => capability.status === 'available')
|
|
||||||
for (const capability of certificateClaims) {
|
|
||||||
const method = METHOD_FOR_CERTIFICATE_ACTION[capability.name]
|
|
||||||
expect(
|
|
||||||
typeof adapter[method],
|
|
||||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
|
||||||
).toBe('function')
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
|
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
for (const capability of autoScalingClaims) {
|
for (const capability of autoScalingClaims) {
|
||||||
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
|
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
|
||||||
@@ -240,7 +225,6 @@ describe('registered adapters honour their schema', () => {
|
|||||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||||
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
||||||
...(adapter.schema().capabilities?.certificateActions ?? []),
|
|
||||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
|||||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||||
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
|
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
|
||||||
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
|
|
||||||
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
|
|
||||||
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||||
import {createEc2Service} from './services/ec2'
|
import {createEc2Service} from './services/ec2'
|
||||||
@@ -107,8 +105,6 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsDnsAdapter(clients.route53),
|
new AwsDnsAdapter(clients.route53),
|
||||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||||
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
|
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
|
||||||
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
|
|
||||||
new AwsPipesAdapter(clients.pipes),
|
|
||||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||||
new AzureStorageAdapter(),
|
new AzureStorageAdapter(),
|
||||||
new AzureServiceBusAdapter(),
|
new AzureServiceBusAdapter(),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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 {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||||
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||||
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
|
||||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||||
@@ -825,34 +824,6 @@ describe('cloud schema routes', () => {
|
|||||||
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('downloads, exports and revokes a certificate through the ACM adapter', async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
|
||||||
service: 'certificates',
|
|
||||||
schema: awsCertificatesSchema,
|
|
||||||
getCertificatePem: async (id) => {
|
|
||||||
calls.push(['pem', id])
|
|
||||||
return {certificate: 'CERT', certificateChain: null}
|
|
||||||
},
|
|
||||||
exportCertificate: async (id, passphrase) => {
|
|
||||||
calls.push(['export', id, passphrase])
|
|
||||||
return {certificate: 'CERT', certificateChain: null, privateKey: 'KEY'}
|
|
||||||
},
|
|
||||||
revokeCertificate: async (id, reason) => {
|
|
||||||
calls.push(['revoke', id, reason])
|
|
||||||
return {id, name: 'a.test', cloud: 'aws', service: 'certificates', type: 'certificate', region: null, createdAt: null, status: 'REVOKED', metadata: {}}
|
|
||||||
},
|
|
||||||
})])
|
|
||||||
const json = (body: unknown) => ({method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
|
||||||
const base = '/api/clouds/aws/services/certificates/resources/c1'
|
|
||||||
|
|
||||||
expect(await (await app.request(`${base}/pem`)).json()).toEqual({certificate: 'CERT', certificateChain: null})
|
|
||||||
expect((await (await app.request(`${base}/export`, json({passphrase: 'secret'}))).json()).privateKey).toBe('KEY')
|
|
||||||
const revoked = await app.request(`${base}/revoke`, json({}))
|
|
||||||
expect((await revoked.json()).status).toBe('REVOKED')
|
|
||||||
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
|
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
|
||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {Context} from 'hono'
|
|||||||
import type {
|
import type {
|
||||||
AlarmStateValue,
|
AlarmStateValue,
|
||||||
AutoScalingCapacityInput,
|
AutoScalingCapacityInput,
|
||||||
CertificateRevocationReason,
|
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
CloudServiceType,
|
CloudServiceType,
|
||||||
CreateDatabaseSnapshotInput,
|
CreateDatabaseSnapshotInput,
|
||||||
@@ -527,33 +526,6 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/:cloud/services/certificates/resources/:id/pem', async (c) => {
|
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
|
||||||
|
|
||||||
return withRuntime(c, async () => c.json(await svc(c).getCertificatePem(cloud, c.req.param('id'))))
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/:cloud/services/certificates/resources/:id/export', 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<{passphrase?: string}>()
|
|
||||||
return c.json(await svc(c).exportCertificate(cloud, c.req.param('id'), body.passphrase as string))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/:cloud/services/certificates/resources/:id/revoke', 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<{reason?: CertificateRevocationReason}>()
|
|
||||||
return c.json(await svc(c).revokeCertificate(cloud, c.req.param('id'), body.reason ?? 'UNSPECIFIED'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.put('/:cloud/services/autoscaling/resources/:id/capacity', async (c) => {
|
app.put('/:cloud/services/autoscaling/resources/:id/capacity', 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)
|
||||||
|
|||||||
@@ -94,9 +94,6 @@ import type {
|
|||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
DistributionInvalidation,
|
DistributionInvalidation,
|
||||||
AutoScalingCapacityInput,
|
AutoScalingCapacityInput,
|
||||||
CertificatePem,
|
|
||||||
CertificateRevocationReason,
|
|
||||||
ExportedCertificate,
|
|
||||||
AlarmHistoryItem,
|
AlarmHistoryItem,
|
||||||
AlarmStateValue,
|
AlarmStateValue,
|
||||||
MetricSeries,
|
MetricSeries,
|
||||||
@@ -946,24 +943,6 @@ export class CloudProxyService {
|
|||||||
return adapter.createDistributionInvalidation(distributionId, paths)
|
return adapter.createDistributionInvalidation(distributionId, paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCertificatePem(cloud: CloudProvider, certificateId: string): Promise<CertificatePem> {
|
|
||||||
const adapter = this.requireAdapter(cloud, 'certificates')
|
|
||||||
if (!adapter.getCertificatePem) throw new NotSupportedError(`Downloading certificates is not supported for ${cloud}/certificates`)
|
|
||||||
return adapter.getCertificatePem(certificateId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportCertificate(cloud: CloudProvider, certificateId: string, passphrase: string): Promise<ExportedCertificate> {
|
|
||||||
const adapter = this.requireAdapter(cloud, 'certificates')
|
|
||||||
if (!adapter.exportCertificate) throw new NotSupportedError(`Exporting certificates is not supported for ${cloud}/certificates`)
|
|
||||||
return adapter.exportCertificate(certificateId, passphrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeCertificate(cloud: CloudProvider, certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> {
|
|
||||||
const adapter = this.requireAdapter(cloud, 'certificates')
|
|
||||||
if (!adapter.revokeCertificate) throw new NotSupportedError(`Revoking certificates is not supported for ${cloud}/certificates`)
|
|
||||||
return adapter.revokeCertificate(certificateId, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||||
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||||
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`)
|
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`)
|
||||||
|
|||||||
@@ -150,11 +150,6 @@ export const apiEndpointKeys = {
|
|||||||
state: "clouds.services.monitoring.state",
|
state: "clouds.services.monitoring.state",
|
||||||
actions: "clouds.services.monitoring.actions",
|
actions: "clouds.services.monitoring.actions",
|
||||||
},
|
},
|
||||||
certificates: {
|
|
||||||
pem: "clouds.services.certificates.pem",
|
|
||||||
export: "clouds.services.certificates.export",
|
|
||||||
revoke: "clouds.services.certificates.revoke",
|
|
||||||
},
|
|
||||||
autoscaling: {
|
autoscaling: {
|
||||||
capacity: "clouds.services.autoscaling.capacity",
|
capacity: "clouds.services.autoscaling.capacity",
|
||||||
terminate: "clouds.services.autoscaling.instances.terminate",
|
terminate: "clouds.services.autoscaling.instances.terminate",
|
||||||
@@ -935,27 +930,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||||
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
||||||
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
||||||
[
|
|
||||||
apiEndpointKeys.clouds.certificates.pem,
|
|
||||||
{
|
|
||||||
path: "/clouds/:cloud/services/certificates/resources/:id/pem",
|
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
apiEndpointKeys.clouds.certificates.export,
|
|
||||||
{
|
|
||||||
path: "/clouds/:cloud/services/certificates/resources/:id/export",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
apiEndpointKeys.clouds.certificates.revoke,
|
|
||||||
{
|
|
||||||
path: "/clouds/:cloud/services/certificates/resources/:id/revoke",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.autoscaling.capacity,
|
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ import type {
|
|||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
DistributionInvalidation,
|
DistributionInvalidation,
|
||||||
AutoScalingCapacityInput,
|
AutoScalingCapacityInput,
|
||||||
CertificatePem,
|
|
||||||
CertificateRevocationReason,
|
|
||||||
ExportedCertificate,
|
|
||||||
AlarmHistoryItem,
|
AlarmHistoryItem,
|
||||||
AlarmStateValue,
|
AlarmStateValue,
|
||||||
MetricSeries,
|
MetricSeries,
|
||||||
@@ -1683,44 +1680,6 @@ export async function testWebSocketApi(cloud: CloudProvider, apiId: string, inpu
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCertificatePem(
|
|
||||||
cloud: CloudProvider,
|
|
||||||
certificateId: string,
|
|
||||||
): Promise<CertificatePem> {
|
|
||||||
const res = await apiClient.call<CertificatePem>(
|
|
||||||
apiEndpointKeys.clouds.certificates.pem,
|
|
||||||
requestOptions(cloud, "certificates"),
|
|
||||||
{ cloud, id: certificateId },
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportCertificate(
|
|
||||||
cloud: CloudProvider,
|
|
||||||
certificateId: string,
|
|
||||||
passphrase: string,
|
|
||||||
): Promise<ExportedCertificate> {
|
|
||||||
const res = await apiClient.call<ExportedCertificate, { passphrase: string }>(
|
|
||||||
apiEndpointKeys.clouds.certificates.export,
|
|
||||||
requestOptions(cloud, "certificates", { body: { passphrase } }),
|
|
||||||
{ cloud, id: certificateId },
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeCertificate(
|
|
||||||
cloud: CloudProvider,
|
|
||||||
certificateId: string,
|
|
||||||
reason: CertificateRevocationReason,
|
|
||||||
): Promise<CloudResource> {
|
|
||||||
const res = await apiClient.call<CloudResource, { reason: CertificateRevocationReason }>(
|
|
||||||
apiEndpointKeys.clouds.certificates.revoke,
|
|
||||||
requestOptions(cloud, "certificates", { body: { reason } }),
|
|
||||||
{ cloud, id: certificateId },
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setAutoScalingCapacity(
|
export async function setAutoScalingCapacity(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
groupName: string,
|
groupName: string,
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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 {CertificatePanel} from './CertificatePanel'
|
|
||||||
import type {CloudResource} from '@/types/resource'
|
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
|
||||||
getCloudResource: vi.fn(),
|
|
||||||
getCertificatePem: vi.fn(),
|
|
||||||
exportCertificate: vi.fn(),
|
|
||||||
revokeCertificate: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
|
||||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
|
||||||
|
|
||||||
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
|
||||||
function certificate(metadata: Record<string, unknown> = {}, status = 'ISSUED'): CloudResource {
|
|
||||||
return {
|
|
||||||
id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate',
|
|
||||||
region: 'us-east-1', createdAt: null, status,
|
|
||||||
metadata: {
|
|
||||||
certificateType: 'Amazon issued', exportable: false, inUseBy: [],
|
|
||||||
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
|
||||||
...metadata,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPanel(resource: CloudResource) {
|
|
||||||
api.getCloudResource.mockResolvedValue(resource)
|
|
||||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
|
||||||
return render(<QueryClientProvider client={client}><CertificatePanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'})
|
|
||||||
api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'})
|
|
||||||
api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED'))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('shows the validation records and that nothing uses the certificate', async () => {
|
|
||||||
renderPanel(certificate())
|
|
||||||
|
|
||||||
expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument()
|
|
||||||
// Export and revoke exist only for certificates requested as exportable, as on AWS.
|
|
||||||
expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('lists what uses the certificate, since ACM will not delete it then', async () => {
|
|
||||||
renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']}))
|
|
||||||
|
|
||||||
expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('gets the certificate and chain as downloadable PEM files', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderPanel(certificate())
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', {name: 'Get certificate'}))
|
|
||||||
expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF')
|
|
||||||
expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA')
|
|
||||||
expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('exports an exportable certificate with the passphrase, then revokes it', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
||||||
renderPanel(certificate({exportable: true}))
|
|
||||||
|
|
||||||
const exportForm = await screen.findByRole('form', {name: 'Export certificate'})
|
|
||||||
await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame')
|
|
||||||
await user.click(within(exportForm).getByRole('button', {name: 'Export'}))
|
|
||||||
expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame')
|
|
||||||
expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----')
|
|
||||||
|
|
||||||
const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'})
|
|
||||||
await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED')
|
|
||||||
await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'}))
|
|
||||||
expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED')
|
|
||||||
expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import {Download, RefreshCw} from 'lucide-react'
|
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
||||||
import {useEffect, useState} from 'react'
|
|
||||||
import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient'
|
|
||||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
|
||||||
import {useAccountId} from '@/lib/accountStore'
|
|
||||||
import {formatDateTime} from '@/lib/format'
|
|
||||||
import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
resource?: CloudResource
|
|
||||||
runtimeReachable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null}
|
|
||||||
|
|
||||||
const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [
|
|
||||||
{value: 'UNSPECIFIED', label: 'Unspecified'},
|
|
||||||
{value: 'KEY_COMPROMISE', label: 'Key compromise'},
|
|
||||||
{value: 'SUPERSEDED', label: 'Superseded'},
|
|
||||||
{value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'},
|
|
||||||
{value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'},
|
|
||||||
{value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'},
|
|
||||||
{value: 'CA_COMPROMISE', label: 'CA compromise'},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function CertificatePanel({resource, runtimeReachable}: Props) {
|
|
||||||
const accountId = useAccountId()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [passphrase, setPassphrase] = useState('')
|
|
||||||
const [reason, setReason] = useState<CertificateRevocationReason>('UNSPECIFIED')
|
|
||||||
const [pem, setPem] = useState<CertificatePem | ExportedCertificate | null>(null)
|
|
||||||
const [notice, setNotice] = useState('')
|
|
||||||
const certificateKey = ['acm-certificate', accountId, resource?.id]
|
|
||||||
|
|
||||||
// The table row is a ListCertificates summary; validation records and users come from DescribeCertificate.
|
|
||||||
const certificateQuery = useQuery({
|
|
||||||
queryKey: certificateKey,
|
|
||||||
queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal),
|
|
||||||
enabled: Boolean(resource) && runtimeReachable,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPassphrase('')
|
|
||||||
setReason('UNSPECIFIED')
|
|
||||||
setPem(null)
|
|
||||||
setNotice('')
|
|
||||||
}, [resource?.id])
|
|
||||||
|
|
||||||
const refresh = () => Promise.all([
|
|
||||||
queryClient.invalidateQueries({queryKey: certificateKey}),
|
|
||||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const pemMutation = useMutation({
|
|
||||||
mutationFn: () => getCertificatePem('aws', resource?.id ?? ''),
|
|
||||||
onSuccess: setPem,
|
|
||||||
})
|
|
||||||
const exportMutation = useMutation({
|
|
||||||
mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase),
|
|
||||||
onSuccess: (exported) => {
|
|
||||||
setPem(exported)
|
|
||||||
setPassphrase('')
|
|
||||||
setNotice('Exported. The private key is encrypted with your passphrase.')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const revokeMutation = useMutation({
|
|
||||||
mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason),
|
|
||||||
onSuccess: () => {
|
|
||||||
setNotice('Certificate revoked. It can no longer be renewed or exported.')
|
|
||||||
return refresh()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return <section className="table-panel"><div className="empty compact"><h3>Select a certificate</h3><p>Choose a certificate to see its validation records, where it is used, and to download or export it.</p></div></section>
|
|
||||||
}
|
|
||||||
|
|
||||||
const certificate = certificateQuery.data ?? resource
|
|
||||||
const metadata = certificate.metadata
|
|
||||||
const validations = list<DomainValidation>(metadata.domainValidations)
|
|
||||||
const inUseBy = list<string>(metadata.inUseBy)
|
|
||||||
const exportable = metadata.exportable === true
|
|
||||||
const issued = certificate.status === 'ISSUED'
|
|
||||||
const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending
|
|
||||||
const disabled = !runtimeReachable || pending
|
|
||||||
const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error
|
|
||||||
const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_')
|
|
||||||
|
|
||||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Certificate ${certificate.name}`}>
|
|
||||||
<div className="input-row resource-table-bar">
|
|
||||||
<div><p className="eyebrow">Certificate</p><span className="muted">{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}</span></div>
|
|
||||||
<div className="cdn-actions">
|
|
||||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); pemMutation.mutate() }}><Download size={14}/>{pemMutation.isPending ? 'Loading' : 'Get certificate'}</button>
|
|
||||||
<button className="button" type="button" disabled={!runtimeReachable || certificateQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{certificateQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{validations.length > 0 && <>
|
|
||||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Domains</p><span className="muted">On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.</span></div></div>
|
|
||||||
<div className="cognito-table"><table className="table"><thead><tr><th>Domain</th><th>Validation</th><th>CNAME name</th><th>CNAME value</th></tr></thead><tbody>{validations.map((validation) => (
|
|
||||||
<tr key={validation.domainName}>
|
|
||||||
<td className="mono">{validation.domainName}</td>
|
|
||||||
<td><code>{validation.status ?? '—'}</code>{validation.method ? ` ${validation.method}` : ''}</td>
|
|
||||||
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.name}<CopyValueButton value={validation.record.name} label="CNAME name"/></span> : '—'}</td>
|
|
||||||
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.value}<CopyValueButton value={validation.record.value} label="CNAME value"/></span> : '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}</tbody></table></div>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">In use by</p><span className="muted">{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}</span></div></div>
|
|
||||||
{inUseBy.length > 0 && <ul className="certificate-users">{inUseBy.map((arn) => <li key={arn} className="mono">{arn}</li>)}</ul>}
|
|
||||||
|
|
||||||
{exportable && issued && <>
|
|
||||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Export</p><span className="muted">Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.</span></div></div>
|
|
||||||
<form className="dynamic-form" aria-label="Export certificate" onSubmit={(event) => { event.preventDefault(); setNotice(''); exportMutation.mutate() }}>
|
|
||||||
<label className="dynamic-field"><span>Passphrase</span>
|
|
||||||
<input className="input" type="password" autoComplete="new-password" value={passphrase} onChange={(event) => setPassphrase(event.target.value)} disabled={disabled} minLength={4} maxLength={128} required/>
|
|
||||||
<small className="muted">4 to 128 characters, without #, $ or %. You need it to decrypt the key.</small>
|
|
||||||
</label>
|
|
||||||
<div><button className="button primary" type="submit" disabled={disabled || passphrase.length < 4}>{exportMutation.isPending ? 'Exporting' : 'Export'}</button></div>
|
|
||||||
</form>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
{exportable && certificate.status !== 'REVOKED' && <>
|
|
||||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Revoke</p><span className="muted">Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.</span></div></div>
|
|
||||||
<form className="dynamic-form" aria-label="Revoke certificate" onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setNotice('')
|
|
||||||
if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate()
|
|
||||||
}}>
|
|
||||||
<label className="dynamic-field"><span>Reason</span>
|
|
||||||
<select className="input" value={reason} onChange={(event) => setReason(event.target.value as CertificateRevocationReason)} disabled={disabled}>
|
|
||||||
{REVOCATION_REASONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div><button className="button danger" type="submit" disabled={disabled}>{revokeMutation.isPending ? 'Revoking' : 'Revoke'}</button></div>
|
|
||||||
</form>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
{pem && <div className="certificate-pem">
|
|
||||||
<PemBlock label="Certificate" value={pem.certificate} file={`${fileStem}.pem`}/>
|
|
||||||
{pem.certificateChain && <PemBlock label="Certificate chain" value={pem.certificateChain} file={`${fileStem}-chain.pem`}/>}
|
|
||||||
{'privateKey' in pem && <PemBlock label="Private key (encrypted)" value={pem.privateKey} file={`${fileStem}-key.pem`}/>}
|
|
||||||
</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 PemBlock({label, value, file}: {label: string; value: string; file: string}) {
|
|
||||||
const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}`
|
|
||||||
return <div className="certificate-pem-block">
|
|
||||||
<div className="certificate-pem-head">
|
|
||||||
<strong>{label}</strong>
|
|
||||||
<CopyValueButton value={value} label={label}/>
|
|
||||||
<a className="button" href={href} download={file}><Download size={13}/> {file}</a>
|
|
||||||
</div>
|
|
||||||
<textarea className="textarea code-textarea small" rows={6} value={value} readOnly aria-label={label}/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function list<T>(value: unknown): T[] {
|
|
||||||
return Array.isArray(value) ? value as T[] : []
|
|
||||||
}
|
|
||||||
@@ -69,8 +69,6 @@ import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
|||||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||||
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
||||||
import { CertificatePanel } from "@/components/CertificatePanel";
|
|
||||||
import { PipePanel } from "@/components/PipePanel";
|
|
||||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||||
@@ -990,12 +988,6 @@ export function DynamicResourceView({
|
|||||||
{service === "dns" && cloud === "aws" && (
|
{service === "dns" && cloud === "aws" && (
|
||||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
{service === "certificates" && cloud === "aws" && (
|
|
||||||
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
||||||
)}
|
|
||||||
{service === "pipes" && cloud === "aws" && (
|
|
||||||
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
|
||||||
)}
|
|
||||||
{service === "autoscaling" && cloud === "aws" && (
|
{service === "autoscaling" && cloud === "aws" && (
|
||||||
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
|
||||||
import {render, screen, within} from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import {MemoryRouter} from 'react-router-dom'
|
|
||||||
import {beforeEach, expect, test, vi} from 'vitest'
|
|
||||||
import {PipePanel} from './PipePanel'
|
|
||||||
import type {CloudResource} from '@/types/resource'
|
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
|
||||||
getCloudResource: vi.fn(),
|
|
||||||
startCloudResource: vi.fn(),
|
|
||||||
stopCloudResource: vi.fn(),
|
|
||||||
sendSqsMessage: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
|
||||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
|
||||||
|
|
||||||
function pipe(status = 'RUNNING', metadata: Record<string, unknown> = {}): CloudResource {
|
|
||||||
return {
|
|
||||||
id: 'orders-pipe', name: 'orders-pipe', cloud: 'aws', service: 'pipes', type: 'pipe', region: 'us-east-1', createdAt: null, status,
|
|
||||||
metadata: {
|
|
||||||
desiredState: status, source: 'arn:aws:sqs:us-east-1:000000000000:pipe-src', sourceName: 'pipe-src (SQS queue)',
|
|
||||||
target: 'arn:aws:lambda:us-east-1:000000000000:function:worker', targetName: 'worker (Lambda function)',
|
|
||||||
filterPatterns: ['{"body":{"kind":["order"]}}'], batchSize: 5, inputTemplate: '{"id": <$.messageId>}',
|
|
||||||
roleArn: 'arn:aws:iam::000000000000:role/pipe-role',
|
|
||||||
...metadata,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPanel(resource: CloudResource) {
|
|
||||||
api.getCloudResource.mockResolvedValue(resource)
|
|
||||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
|
||||||
return render(<MemoryRouter><QueryClientProvider client={client}><PipePanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
api.startCloudResource.mockResolvedValue(undefined)
|
|
||||||
api.stopCloudResource.mockResolvedValue(undefined)
|
|
||||||
api.sendSqsMessage.mockResolvedValue({messageId: 'm-1'})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('shows the flow from source through the filter to the target, linked to each service', async () => {
|
|
||||||
renderPanel(pipe())
|
|
||||||
|
|
||||||
const flow = await screen.findByRole('list', {name: 'Event flow'})
|
|
||||||
expect(within(flow).getByRole('link', {name: 'pipe-src (SQS queue)'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
|
|
||||||
expect(within(flow).getByRole('link', {name: 'worker (Lambda function)'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
|
|
||||||
expect(within(flow).getByText('{"body":{"kind":["order"]}}')).toBeInTheDocument()
|
|
||||||
expect(within(flow).getByText('batches of 5')).toBeInTheDocument()
|
|
||||||
expect(within(flow).getByText('{"id": <$.messageId>}')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('stops a running pipe', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderPanel(pipe())
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', {name: 'Stop'}))
|
|
||||||
expect(api.stopCloudResource).toHaveBeenCalledWith('aws', 'pipes', 'orders-pipe')
|
|
||||||
expect(await screen.findByText(/Pipe stopped/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('sends a test message to an SQS source queue', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderPanel(pipe())
|
|
||||||
|
|
||||||
const form = await screen.findByRole('form', {name: 'Send test message'})
|
|
||||||
await user.click(within(form).getByRole('button', {name: 'Send to pipe-src'}))
|
|
||||||
expect(api.sendSqsMessage).toHaveBeenCalledWith('aws', 'pipe-src', {messageBody: '{"kind": "order", "id": 1}'})
|
|
||||||
expect(await screen.findByText(/Sent message m-1/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('offers no test message for a stream source, and cannot toggle a pipe mid-change', async () => {
|
|
||||||
renderPanel(pipe('STARTING', {source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', sourceName: 'clicks (Kinesis stream)', desiredState: 'RUNNING'}))
|
|
||||||
|
|
||||||
expect(await screen.findByRole('link', {name: 'clicks (Kinesis stream)'})).toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('form', {name: 'Send test message'})).not.toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', {name: 'Start'})).toBeDisabled()
|
|
||||||
})
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import {ArrowRight, RefreshCw} from 'lucide-react'
|
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
||||||
import {type ReactNode, useEffect, useState} from 'react'
|
|
||||||
import {Link} from 'react-router-dom'
|
|
||||||
import {getCloudResource, sendSqsMessage, startCloudResource, stopCloudResource} from '@/api/cloudProxyClient'
|
|
||||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
|
||||||
import {useAccountId} from '@/lib/accountStore'
|
|
||||||
import type {CloudResource} from '@/types/resource'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
resource?: CloudResource
|
|
||||||
runtimeReachable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where each ARN's service lives in TheCloud, so a step links to the resource behind it. */
|
|
||||||
const CONSOLE_SERVICE: Record<string, string> = {
|
|
||||||
sqs: 'messaging',
|
|
||||||
lambda: 'serverless',
|
|
||||||
sns: 'notifications',
|
|
||||||
events: 'events',
|
|
||||||
states: 'workflows',
|
|
||||||
kinesis: 'kinesis',
|
|
||||||
dynamodb: 'nosql',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAMPLE_MESSAGE = '{"kind": "order", "id": 1}'
|
|
||||||
|
|
||||||
export function PipePanel({resource, runtimeReachable}: Props) {
|
|
||||||
const accountId = useAccountId()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [message, setMessage] = useState(SAMPLE_MESSAGE)
|
|
||||||
const [notice, setNotice] = useState('')
|
|
||||||
const pipeKey = ['pipe', accountId, resource?.id]
|
|
||||||
|
|
||||||
// The table row is a ListPipes entry; the filter, template and role come from DescribePipe.
|
|
||||||
const pipeQuery = useQuery({
|
|
||||||
queryKey: pipeKey,
|
|
||||||
queryFn: ({signal}) => getCloudResource('aws', 'pipes', resource?.id ?? '', signal),
|
|
||||||
enabled: Boolean(resource) && runtimeReachable,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMessage(SAMPLE_MESSAGE)
|
|
||||||
setNotice('')
|
|
||||||
}, [resource?.id])
|
|
||||||
|
|
||||||
const refresh = () => Promise.all([
|
|
||||||
queryClient.invalidateQueries({queryKey: pipeKey}),
|
|
||||||
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'pipes')}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const stateMutation = useMutation({
|
|
||||||
mutationFn: (running: boolean) => running ? startCloudResource('aws', 'pipes', resource?.id ?? '') : stopCloudResource('aws', 'pipes', resource?.id ?? ''),
|
|
||||||
onSuccess: (_result, running) => {
|
|
||||||
setNotice(running ? 'Pipe started. It reads the source again.' : 'Pipe stopped. Records wait in the source until it starts again.')
|
|
||||||
return refresh()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const sendMutation = useMutation({
|
|
||||||
mutationFn: (queue: string) => sendSqsMessage('aws', queue, {messageBody: message}),
|
|
||||||
onSuccess: (sent) => setNotice(`Sent message ${sent.messageId} to the source queue. The pipe picks it up within a few seconds.`),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return <section className="table-panel"><div className="empty compact"><h3>Select a pipe</h3><p>Choose a pipe to see where its events flow, start or stop it, or send it a test message.</p></div></section>
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipe = pipeQuery.data ?? resource
|
|
||||||
const metadata = pipe.metadata
|
|
||||||
const state = pipe.status ?? 'UNKNOWN'
|
|
||||||
const running = state === 'RUNNING'
|
|
||||||
const filters = Array.isArray(metadata.filterPatterns) ? metadata.filterPatterns as string[] : []
|
|
||||||
const source = text(metadata.source)
|
|
||||||
const sourceQueue = source.split(':')[2] === 'sqs' ? source.split(':')[5] : ''
|
|
||||||
const pending = stateMutation.isPending || sendMutation.isPending
|
|
||||||
const disabled = !runtimeReachable || pending
|
|
||||||
const error = pipeQuery.error ?? stateMutation.error ?? sendMutation.error
|
|
||||||
|
|
||||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Pipe ${pipe.name}`}>
|
|
||||||
<div className="input-row resource-table-bar">
|
|
||||||
<div><p className="eyebrow">Pipe</p><span className="muted">{state}{metadata.desiredState && metadata.desiredState !== state ? ` · going to ${String(metadata.desiredState)}` : ''}{metadata.description ? ` · ${String(metadata.description)}` : ''}</span></div>
|
|
||||||
<div className="cdn-actions">
|
|
||||||
<button className="button" type="button" disabled={disabled || !['RUNNING', 'STOPPED'].includes(state)} onClick={() => { setNotice(''); stateMutation.mutate(!running) }}>{running ? 'Stop' : 'Start'}</button>
|
|
||||||
<button className="button" type="button" disabled={!runtimeReachable || pipeQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{pipeQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{typeof metadata.stateReason === 'string' && metadata.stateReason && <p className="error-text compact-text pipe-reason">{metadata.stateReason}</p>}
|
|
||||||
|
|
||||||
<ol className="pipe-flow" aria-label="Event flow">
|
|
||||||
<PipeStep label="Source" arn={source} name={metadata.sourceName} detail={sourceDetail(metadata)}/>
|
|
||||||
{filters.length > 0 && <li className="pipe-step"><span className="eyebrow">Filter</span>{filters.map((pattern) => <code key={pattern} className="pipe-code">{pattern}</code>)}<ArrowRight size={14} className="pipe-arrow" aria-hidden/></li>}
|
|
||||||
{text(metadata.enrichment) && <PipeStep label="Enrichment" arn={text(metadata.enrichment)} name={null}/>}
|
|
||||||
<PipeStep label="Target" arn={text(metadata.target)} name={metadata.targetName} detail={text(metadata.inputTemplate) ? <>input template <code className="pipe-code">{text(metadata.inputTemplate)}</code></> : undefined}/>
|
|
||||||
</ol>
|
|
||||||
{text(metadata.roleArn) && <p className="muted compact-text pipe-role">Runs as <code>{text(metadata.roleArn)}</code></p>}
|
|
||||||
|
|
||||||
{sourceQueue && <>
|
|
||||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Try it</p><span className="muted">Send a message to the source queue{filters.length > 0 ? '. Only messages matching the filter reach the target' : ''}.</span></div></div>
|
|
||||||
<form className="dynamic-form" aria-label="Send test message" onSubmit={(event) => { event.preventDefault(); setNotice(''); sendMutation.mutate(sourceQueue) }}>
|
|
||||||
<label className="dynamic-field dynamic-field--span"><span>Message body</span>
|
|
||||||
<textarea className="textarea code-textarea small" rows={3} value={message} onChange={(event) => setMessage(event.target.value)} disabled={disabled} required/>
|
|
||||||
</label>
|
|
||||||
<div><button className="button primary" type="submit" disabled={disabled || !message.trim()}>{sendMutation.isPending ? 'Sending' : `Send to ${sourceQueue}`}</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>}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
function PipeStep({label, arn, name, detail}: {label: string; arn: string; name: unknown; detail?: ReactNode}) {
|
|
||||||
const service = CONSOLE_SERVICE[arn.split(':')[2] ?? '']
|
|
||||||
const title = typeof name === 'string' && name ? name : arn
|
|
||||||
return <li className="pipe-step">
|
|
||||||
<span className="eyebrow">{label}</span>
|
|
||||||
{service ? <Link to={`/cloud-explorer/aws/${service}`} className="mono">{title}</Link> : <span className="mono">{title}</span>}
|
|
||||||
<small className="muted mono pipe-arn">{arn}</small>
|
|
||||||
{detail && <small className="muted">{detail}</small>}
|
|
||||||
<ArrowRight size={14} className="pipe-arrow" aria-hidden/>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceDetail(metadata: Record<string, unknown>): ReactNode {
|
|
||||||
const parts = [
|
|
||||||
metadata.batchSize ? `batches of ${String(metadata.batchSize)}` : '',
|
|
||||||
metadata.startingPosition ? `from ${String(metadata.startingPosition)}` : '',
|
|
||||||
metadata.topicName ? `topic ${String(metadata.topicName)}` : '',
|
|
||||||
].filter(Boolean)
|
|
||||||
return parts.length > 0 ? parts.join(' · ') : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value : ''
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Database,
|
Database,
|
||||||
Earth,
|
Earth,
|
||||||
FileBadge,
|
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Hammer,
|
Hammer,
|
||||||
@@ -39,7 +38,6 @@ import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/
|
|||||||
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.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 cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||||
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
||||||
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
|
||||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.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'
|
||||||
@@ -91,7 +89,6 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
iac: Layers,
|
iac: Layers,
|
||||||
messaging: MessageSquare,
|
messaging: MessageSquare,
|
||||||
events: Radio,
|
events: Radio,
|
||||||
pipes: Workflow,
|
|
||||||
email: MessageSquare,
|
email: MessageSquare,
|
||||||
queue: MessageSquare,
|
queue: MessageSquare,
|
||||||
logs: ScrollText,
|
logs: ScrollText,
|
||||||
@@ -101,7 +98,6 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
dns: Globe,
|
dns: Globe,
|
||||||
cdn: Earth,
|
cdn: Earth,
|
||||||
autoscaling: Scaling,
|
autoscaling: Scaling,
|
||||||
certificates: FileBadge,
|
|
||||||
monitoring: Activity,
|
monitoring: Activity,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
parameters: SlidersHorizontal,
|
parameters: SlidersHorizontal,
|
||||||
@@ -128,7 +124,6 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
ecr: ecrIcon,
|
ecr: ecrIcon,
|
||||||
email: sesIcon,
|
email: sesIcon,
|
||||||
events: eventBridgeIcon,
|
events: eventBridgeIcon,
|
||||||
pipes: eventBridgeIcon,
|
|
||||||
iac: cloudFormationIcon,
|
iac: cloudFormationIcon,
|
||||||
iam: iamIcon,
|
iam: iamIcon,
|
||||||
kms: kmsIcon,
|
kms: kmsIcon,
|
||||||
@@ -151,7 +146,6 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
dns: route53Icon,
|
dns: route53Icon,
|
||||||
cdn: cloudFrontIcon,
|
cdn: cloudFrontIcon,
|
||||||
autoscaling: autoScalingIcon,
|
autoscaling: autoScalingIcon,
|
||||||
certificates: certificateManagerIcon,
|
|
||||||
monitoring: cloudWatchIcon,
|
monitoring: cloudWatchIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6611,21 +6611,6 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
|||||||
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
||||||
.metric-chart-table table { margin-top: 8px; }
|
.metric-chart-table table { margin-top: 8px; }
|
||||||
|
|
||||||
/* ACM: the resources using a certificate, and its PEM blocks. */
|
|
||||||
.certificate-users { margin: 0; padding: 0 16px 12px 32px; display: grid; gap: 4px; overflow-wrap: anywhere; }
|
|
||||||
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
|
|
||||||
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
|
|
||||||
.certificate-pem-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
||||||
|
|
||||||
/* EventBridge Pipes: source, filter, enrichment and target as one flow. */
|
|
||||||
.pipe-flow { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 0; padding: 4px 16px 12px; list-style: none; }
|
|
||||||
.pipe-step { position: relative; display: grid; gap: 4px; align-content: start; min-width: 0; max-width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--raised); }
|
|
||||||
.pipe-step .pipe-arrow { position: absolute; top: 50%; right: -22px; transform: translateY(-50%); color: var(--text-3); }
|
|
||||||
.pipe-step:last-child .pipe-arrow { display: none; }
|
|
||||||
.pipe-arn, .pipe-code { overflow-wrap: anywhere; }
|
|
||||||
.pipe-code { font-family: var(--mono); font-size: 11px; white-space: pre-wrap; }
|
|
||||||
.pipe-role, .pipe-reason { padding: 0 16px 12px; margin: 0; overflow-wrap: anywhere; }
|
|
||||||
|
|
||||||
/* EC2 Auto Scaling: capacity fields side by side, instance actions in one row. */
|
/* EC2 Auto Scaling: capacity fields side by side, instance actions in one row. */
|
||||||
.autoscaling-capacity { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); align-items: end; padding: 4px 16px 12px; }
|
.autoscaling-capacity { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); align-items: end; padding: 4px 16px 12px; }
|
||||||
.autoscaling-note { padding: 0 16px 12px; margin: 0; }
|
.autoscaling-note { padding: 0 16px 12px; margin: 0; }
|
||||||
|
|||||||
@@ -337,25 +337,6 @@ export interface AlarmHistoryItem {
|
|||||||
summary: string
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CertificatePem {
|
|
||||||
certificate: string
|
|
||||||
certificateChain: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The private key is PEM, encrypted with the passphrase given to export. */
|
|
||||||
export interface ExportedCertificate extends CertificatePem {
|
|
||||||
privateKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CertificateRevocationReason =
|
|
||||||
| 'UNSPECIFIED'
|
|
||||||
| 'KEY_COMPROMISE'
|
|
||||||
| 'CA_COMPROMISE'
|
|
||||||
| 'AFFILIATION_CHANGED'
|
|
||||||
| 'SUPERSEDED'
|
|
||||||
| 'CESSATION_OF_OPERATION'
|
|
||||||
| 'PRIVILEGE_WITHDRAWN'
|
|
||||||
|
|
||||||
export interface AutoScalingCapacityInput {
|
export interface AutoScalingCapacityInput {
|
||||||
minSize?: number
|
minSize?: number
|
||||||
desiredCapacity?: number
|
desiredCapacity?: number
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export type KubernetesActionName =
|
|||||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
|
||||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||||
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'
|
||||||
@@ -94,7 +93,6 @@ export interface ServiceSchema {
|
|||||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||||
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
|
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
|
||||||
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
|
||||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||||
}
|
}
|
||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
|
|||||||
Generated
-36
@@ -10,9 +10,6 @@ importers:
|
|||||||
|
|
||||||
packages/api:
|
packages/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/client-acm':
|
|
||||||
specifier: ^3.1146.0
|
|
||||||
version: 3.1156.0
|
|
||||||
'@aws-sdk/client-api-gateway':
|
'@aws-sdk/client-api-gateway':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1096.0
|
version: 3.1096.0
|
||||||
@@ -94,9 +91,6 @@ importers:
|
|||||||
'@aws-sdk/client-lightsail':
|
'@aws-sdk/client-lightsail':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1148.0
|
version: 3.1148.0
|
||||||
'@aws-sdk/client-pipes':
|
|
||||||
specifier: ^3.1146.0
|
|
||||||
version: 3.1156.0
|
|
||||||
'@aws-sdk/client-rds':
|
'@aws-sdk/client-rds':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1076.0
|
version: 3.1076.0
|
||||||
@@ -307,10 +301,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==}
|
resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-acm@3.1156.0':
|
|
||||||
resolution: {integrity: sha512-vRy8mVgYOO1E/YXDlBuXCUKsmNKkVjKFIW2ZS30kWZydbS6CnqFxNkMajDzqJcm+PZ74rwgnhNHsJkc+UIEMzQ==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@aws-sdk/client-api-gateway@3.1096.0':
|
'@aws-sdk/client-api-gateway@3.1096.0':
|
||||||
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
|
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -419,10 +409,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
|
resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-pipes@3.1156.0':
|
|
||||||
resolution: {integrity: sha512-sHtPycrT80c+bEkQgFJIh+CzpuqjGhhdGtVEQfBU+APmHF01SZEJrbnY7MZEHEJZl0iVgLuasxtlYQO+rhAkoQ==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@aws-sdk/client-rds-data@3.1144.0':
|
'@aws-sdk/client-rds-data@3.1144.0':
|
||||||
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
|
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2917,17 +2903,6 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-acm@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-api-gateway@3.1096.0':
|
'@aws-sdk/client-api-gateway@3.1096.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.977.1
|
'@aws-sdk/core': 3.977.1
|
||||||
@@ -3239,17 +3214,6 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-pipes@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-rds-data@3.1144.0':
|
'@aws-sdk/client-rds-data@3.1144.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.1
|
||||||
|
|||||||
Reference in New Issue
Block a user