feat: ElastiCache
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateCacheClusterCommand,
|
||||
CreateReplicationGroupCommand,
|
||||
DeleteCacheClusterCommand,
|
||||
DeleteReplicationGroupCommand,
|
||||
DescribeCacheClustersCommand,
|
||||
DescribeReplicationGroupsCommand,
|
||||
type ElastiCacheClient,
|
||||
} from '@aws-sdk/client-elasticache'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {AwsElastiCacheAdapter} from './AwsElastiCacheAdapter'
|
||||
|
||||
type ElastiCacheCommand =
|
||||
| CreateCacheClusterCommand
|
||||
| CreateReplicationGroupCommand
|
||||
| DeleteCacheClusterCommand
|
||||
| DeleteReplicationGroupCommand
|
||||
| DescribeCacheClustersCommand
|
||||
| DescribeReplicationGroupsCommand
|
||||
|
||||
function recordingClient(responder: (command: ElastiCacheCommand) => unknown | Promise<unknown>) {
|
||||
const commands: ElastiCacheCommand[] = []
|
||||
const client = {
|
||||
async send(command: ElastiCacheCommand) {
|
||||
commands.push(command)
|
||||
return responder(command)
|
||||
},
|
||||
} as unknown as ElastiCacheClient
|
||||
return {client, commands}
|
||||
}
|
||||
|
||||
describe('AwsElastiCacheAdapter', () => {
|
||||
test('lists replication groups and cache clusters with native resource metadata', async () => {
|
||||
const {client} = recordingClient((command) => {
|
||||
if (command instanceof DescribeReplicationGroupsCommand) return {ReplicationGroups: [{
|
||||
ReplicationGroupId: 'orders', ARN: 'arn:aws:elasticache:us-east-1:000000000000:replicationgroup:orders',
|
||||
Status: 'available', Engine: 'valkey', CacheNodeType: 'cache.t3.micro', ClusterEnabled: true,
|
||||
ConfigurationEndpoint: {Address: 'orders.cfg.local', Port: 6379},
|
||||
NodeGroups: [{NodeGroupId: '0001', PrimaryEndpoint: {Address: 'orders-001.local', Port: 6379}}],
|
||||
}]}
|
||||
return {CacheClusters: [{
|
||||
CacheClusterId: 'sessions', ARN: 'arn:aws:elasticache:us-east-1:000000000000:cluster:sessions',
|
||||
CacheClusterStatus: 'available', Engine: 'memcached', EngineVersion: '1.6.22', CacheNodeType: 'cache.t3.micro',
|
||||
CacheClusterCreateTime: new Date('2026-10-05T10:00:00.000Z'), NumCacheNodes: 1,
|
||||
CacheNodes: [{CacheNodeId: '0001', Endpoint: {Address: 'sessions.local', Port: 11211}}],
|
||||
}]}
|
||||
})
|
||||
|
||||
const resources = await new AwsElastiCacheAdapter(client).list()
|
||||
|
||||
expect(resources).toEqual([
|
||||
expect.objectContaining({id: 'orders', type: 'replication-group', engine: 'valkey', status: 'available', region: 'us-east-1'}),
|
||||
expect.objectContaining({id: 'sessions', type: 'cache-cluster', engine: 'memcached', version: '1.6.22', createdAt: '2026-10-05T10:00:00.000Z'}),
|
||||
])
|
||||
expect(resources[0]?.metadata).toMatchObject({endpoint: 'orders.cfg.local', clusterEnabled: true, nodeGroups: [{NodeGroupId: '0001'}]})
|
||||
expect(resources[1]?.metadata).toMatchObject({endpoint: 'sessions.local', numCacheNodes: 1})
|
||||
})
|
||||
|
||||
test('creates a cluster-mode replication group with its AWS topology input', async () => {
|
||||
const {client, commands} = recordingClient((command) => {
|
||||
expect(command).toBeInstanceOf(CreateReplicationGroupCommand)
|
||||
return {ReplicationGroup: {
|
||||
ReplicationGroupId: 'orders', ReplicationGroupDescription: 'Order cache', Engine: 'redis',
|
||||
CacheNodeType: 'cache.t3.micro', Status: 'creating',
|
||||
}}
|
||||
})
|
||||
|
||||
const resource = await new AwsElastiCacheAdapter(client).create({values: {
|
||||
kind: 'replication-group', id: ' orders ', description: ' Order cache ', engine: 'redis',
|
||||
nodeType: 'cache.t3.micro', port: '6379', clusterMode: 'enabled', numNodeGroups: '2', replicasPerNodeGroup: '1',
|
||||
}})
|
||||
|
||||
expect((commands[0] as CreateReplicationGroupCommand).input).toEqual({
|
||||
ReplicationGroupId: 'orders', ReplicationGroupDescription: 'Order cache', Engine: 'redis',
|
||||
CacheNodeType: 'cache.t3.micro', Port: 6379, NumNodeGroups: 2, ReplicasPerNodeGroup: 1,
|
||||
})
|
||||
expect(resource).toMatchObject({id: 'orders', type: 'replication-group', status: 'creating'})
|
||||
})
|
||||
|
||||
test('creates a cache cluster with the cache-cluster AWS contract', async () => {
|
||||
const {client, commands} = recordingClient((command) => {
|
||||
expect(command).toBeInstanceOf(CreateCacheClusterCommand)
|
||||
return {CacheCluster: {CacheClusterId: 'sessions', Engine: 'memcached', CacheNodeType: 'cache.t3.micro', CacheClusterStatus: 'creating'}}
|
||||
})
|
||||
|
||||
await new AwsElastiCacheAdapter(client).create({values: {
|
||||
kind: 'cache-cluster', id: 'sessions', engine: 'memcached', nodeType: 'cache.t3.micro', numCacheNodes: '2',
|
||||
}})
|
||||
|
||||
expect((commands[0] as CreateCacheClusterCommand).input).toEqual({
|
||||
CacheClusterId: 'sessions', Engine: 'memcached', CacheNodeType: 'cache.t3.micro', NumCacheNodes: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects invalid resource combinations before sending a command', async () => {
|
||||
const {client, commands} = recordingClient(() => ({}))
|
||||
const adapter = new AwsElastiCacheAdapter(client)
|
||||
|
||||
await expect(adapter.create({values: {
|
||||
kind: 'replication-group', id: 'orders', description: 'Orders', engine: 'memcached', nodeType: 'cache.t3.micro',
|
||||
}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {
|
||||
kind: 'cache-cluster', id: 'orders--cache', engine: 'redis', nodeType: 'cache.t3.micro',
|
||||
}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(commands).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('deletes replication groups and cache clusters through their respective commands', async () => {
|
||||
const {client, commands} = recordingClient((command) => {
|
||||
if (command instanceof DescribeReplicationGroupsCommand && command.input.ReplicationGroupId === 'orders') {
|
||||
return {ReplicationGroups: [{ReplicationGroupId: 'orders'}]}
|
||||
}
|
||||
if (command instanceof DescribeReplicationGroupsCommand) {
|
||||
throw Object.assign(new Error('not found'), {name: 'ReplicationGroupNotFoundFault'})
|
||||
}
|
||||
if (command instanceof DescribeCacheClustersCommand) return {CacheClusters: [{CacheClusterId: 'sessions'}]}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsElastiCacheAdapter(client)
|
||||
|
||||
await adapter.delete('orders')
|
||||
await adapter.delete('sessions')
|
||||
|
||||
expect(commands.filter((command) => command instanceof DeleteReplicationGroupCommand)[0]?.input).toEqual({
|
||||
ReplicationGroupId: 'orders', RetainPrimaryCluster: false,
|
||||
})
|
||||
expect(commands.filter((command) => command instanceof DeleteCacheClusterCommand)[0]?.input).toEqual({CacheClusterId: 'sessions'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
CreateCacheClusterCommand,
|
||||
CreateReplicationGroupCommand,
|
||||
DeleteCacheClusterCommand,
|
||||
DeleteReplicationGroupCommand,
|
||||
DescribeCacheClustersCommand,
|
||||
DescribeReplicationGroupsCommand,
|
||||
type CacheCluster,
|
||||
type ElastiCacheClient,
|
||||
type ReplicationGroup,
|
||||
} from '@aws-sdk/client-elasticache'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {awsElastiCacheSchema} from '../cloud-spi/elasticacheSchema'
|
||||
import {awsClients} from '../aws'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
const IDENTIFIER = /^(?!.*--)[a-z][a-z0-9-]{0,38}[a-z0-9]$|^[a-z]$/
|
||||
|
||||
export class AwsElastiCacheAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'elasticache' as const
|
||||
|
||||
constructor(private readonly elasticache: ElastiCacheClient = awsClients.elasticache) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsElastiCacheSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const [groups, clusters] = await Promise.all([this.replicationGroups(), this.cacheClusters()])
|
||||
const resources = [...groups.map(toReplicationGroupResource), ...clusters.map(toCacheClusterResource)]
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return search ? resources.filter((resource) => resource.name.toLowerCase().includes(search)) : resources
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const group = await this.getReplicationGroup(id)
|
||||
if (group) return toReplicationGroupResource(group)
|
||||
const cluster = await this.getCacheCluster(id)
|
||||
return cluster ? toCacheClusterResource(cluster) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const id = identifier(input.values.id)
|
||||
const kind = cacheKind(input.values.kind)
|
||||
const engine = cacheEngine(input.values.engine)
|
||||
const nodeType = text(input.values.nodeType)
|
||||
if (!nodeType) throw new ValidationError('nodeType is required')
|
||||
const port = optionalPort(input.values.port)
|
||||
|
||||
if (kind === 'replication-group') {
|
||||
if (engine === 'memcached') throw new ValidationError('memcached is supported only for cache clusters')
|
||||
const description = text(input.values.description)
|
||||
if (!description) throw new ValidationError('description is required for replication groups')
|
||||
const clusterMode = text(input.values.clusterMode) || 'disabled'
|
||||
if (clusterMode !== 'disabled' && clusterMode !== 'enabled') {
|
||||
throw new ValidationError('clusterMode must be disabled or enabled')
|
||||
}
|
||||
const response = await this.elasticache.send(new CreateReplicationGroupCommand({
|
||||
ReplicationGroupId: id,
|
||||
ReplicationGroupDescription: description,
|
||||
Engine: engine,
|
||||
CacheNodeType: nodeType,
|
||||
...(port !== undefined && {Port: port}),
|
||||
...(clusterMode === 'enabled' && {
|
||||
NumNodeGroups: positiveInteger(input.values.numNodeGroups, 'numNodeGroups'),
|
||||
ReplicasPerNodeGroup: nonNegativeInteger(input.values.replicasPerNodeGroup, 'replicasPerNodeGroup'),
|
||||
}),
|
||||
}))
|
||||
if (!response.ReplicationGroup) throw new ValidationError('ElastiCache did not return the replication group')
|
||||
return toReplicationGroupResource(response.ReplicationGroup)
|
||||
}
|
||||
|
||||
const response = await this.elasticache.send(new CreateCacheClusterCommand({
|
||||
CacheClusterId: id,
|
||||
Engine: engine,
|
||||
CacheNodeType: nodeType,
|
||||
NumCacheNodes: positiveInteger(input.values.numCacheNodes ?? '1', 'numCacheNodes'),
|
||||
...(port !== undefined && {Port: port}),
|
||||
}))
|
||||
if (!response.CacheCluster) throw new ValidationError('ElastiCache did not return the cache cluster')
|
||||
return toCacheClusterResource(response.CacheCluster)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (await this.getReplicationGroup(id)) {
|
||||
await this.elasticache.send(new DeleteReplicationGroupCommand({ReplicationGroupId: id, RetainPrimaryCluster: false}))
|
||||
return
|
||||
}
|
||||
if (await this.getCacheCluster(id)) {
|
||||
await this.elasticache.send(new DeleteCacheClusterCommand({CacheClusterId: id}))
|
||||
return
|
||||
}
|
||||
// Let AWS return its native typed not-found error, which the shared mapper translates.
|
||||
await this.elasticache.send(new DeleteCacheClusterCommand({CacheClusterId: id}))
|
||||
}
|
||||
|
||||
private async replicationGroups(): Promise<ReplicationGroup[]> {
|
||||
const groups: ReplicationGroup[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const response = await this.elasticache.send(new DescribeReplicationGroupsCommand({Marker: marker}))
|
||||
groups.push(...(response.ReplicationGroups ?? []))
|
||||
marker = response.Marker
|
||||
} while (marker)
|
||||
return groups
|
||||
}
|
||||
|
||||
private async cacheClusters(): Promise<CacheCluster[]> {
|
||||
const clusters: CacheCluster[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const response = await this.elasticache.send(new DescribeCacheClustersCommand({Marker: marker, ShowCacheNodeInfo: true}))
|
||||
clusters.push(...(response.CacheClusters ?? []))
|
||||
marker = response.Marker
|
||||
} while (marker)
|
||||
return clusters
|
||||
}
|
||||
|
||||
private async getReplicationGroup(id: string): Promise<ReplicationGroup | null> {
|
||||
try {
|
||||
const response = await this.elasticache.send(new DescribeReplicationGroupsCommand({ReplicationGroupId: id}))
|
||||
return response.ReplicationGroups?.[0] ?? null
|
||||
} catch (error) {
|
||||
if (isNotFound(error, 'ReplicationGroupNotFoundFault')) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async getCacheCluster(id: string): Promise<CacheCluster | null> {
|
||||
try {
|
||||
const response = await this.elasticache.send(new DescribeCacheClustersCommand({CacheClusterId: id, ShowCacheNodeInfo: true}))
|
||||
return response.CacheClusters?.[0] ?? null
|
||||
} catch (error) {
|
||||
if (isNotFound(error, 'CacheClusterNotFound')) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toReplicationGroupResource(group: ReplicationGroup): CloudResource {
|
||||
const id = group.ReplicationGroupId ?? ''
|
||||
const primaryEndpoint = group.NodeGroups?.[0]?.PrimaryEndpoint
|
||||
const readerEndpoint = group.NodeGroups?.[0]?.ReaderEndpoint
|
||||
const endpoint = group.ConfigurationEndpoint?.Address ?? primaryEndpoint?.Address ?? null
|
||||
return {
|
||||
id, name: id, cloud: 'aws', service: 'elasticache', type: 'replication-group',
|
||||
region: regionFromArn(group.ARN), createdAt: null, status: group.Status ?? null,
|
||||
engine: group.Engine ?? null, version: null, instanceClass: group.CacheNodeType ?? null,
|
||||
metadata: {
|
||||
arn: group.ARN, status: group.Status, engine: group.Engine, nodeType: group.CacheNodeType, endpoint,
|
||||
primaryEndpoint, readerEndpoint,
|
||||
configurationEndpoint: group.ConfigurationEndpoint, memberClusters: group.MemberClusters ?? [],
|
||||
clusterEnabled: group.ClusterEnabled, nodeGroups: group.NodeGroups ?? [],
|
||||
endpoints: {primary: primaryEndpoint, reader: readerEndpoint, configuration: group.ConfigurationEndpoint},
|
||||
topology: {clusterEnabled: group.ClusterEnabled, nodeGroups: group.NodeGroups ?? []},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toCacheClusterResource(cluster: CacheCluster): CloudResource {
|
||||
const id = cluster.CacheClusterId ?? ''
|
||||
const endpoint = cluster.ConfigurationEndpoint?.Address ?? cluster.CacheNodes?.[0]?.Endpoint?.Address ?? null
|
||||
return {
|
||||
id, name: id, cloud: 'aws', service: 'elasticache', type: 'cache-cluster',
|
||||
region: regionFromArn(cluster.ARN), createdAt: cluster.CacheClusterCreateTime?.toISOString() ?? null,
|
||||
status: cluster.CacheClusterStatus ?? null, engine: cluster.Engine ?? null,
|
||||
version: cluster.EngineVersion ?? null, instanceClass: cluster.CacheNodeType ?? null,
|
||||
metadata: {
|
||||
arn: cluster.ARN, status: cluster.CacheClusterStatus, engine: cluster.Engine, nodeType: cluster.CacheNodeType, endpoint,
|
||||
configurationEndpoint: cluster.ConfigurationEndpoint, cacheNodes: cluster.CacheNodes ?? [],
|
||||
numCacheNodes: cluster.NumCacheNodes, replicationGroupId: cluster.ReplicationGroupId,
|
||||
preferredAvailabilityZone: cluster.PreferredAvailabilityZone,
|
||||
endpoints: {configuration: cluster.ConfigurationEndpoint, nodes: cluster.CacheNodes ?? []},
|
||||
topology: {numCacheNodes: cluster.NumCacheNodes, replicationGroupId: cluster.ReplicationGroupId, cacheNodes: cluster.CacheNodes ?? []},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
const id = text(value)
|
||||
if (!IDENTIFIER.test(id)) {
|
||||
throw new ValidationError('id must be 1-40 lowercase letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen')
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function cacheKind(value: unknown): 'replication-group' | 'cache-cluster' {
|
||||
const kind = text(value)
|
||||
if (kind === 'replication-group' || kind === 'cache-cluster') return kind
|
||||
throw new ValidationError('kind must be replication-group or cache-cluster')
|
||||
}
|
||||
|
||||
function cacheEngine(value: unknown): 'redis' | 'valkey' | 'memcached' {
|
||||
const engine = text(value)
|
||||
if (engine === 'redis' || engine === 'valkey' || engine === 'memcached') return engine
|
||||
throw new ValidationError('engine must be redis, valkey, or memcached')
|
||||
}
|
||||
|
||||
function optionalPort(value: unknown): number | undefined {
|
||||
if (value === undefined || text(value) === '') return undefined
|
||||
return positiveInteger(value, 'port', 65535)
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, field: string, maximum?: number): number {
|
||||
const parsed = Number(text(value))
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || (maximum !== undefined && parsed > maximum)) {
|
||||
throw new ValidationError(`${field} must be a positive whole number${maximum ? ` up to ${maximum}` : ''}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, field: string): number {
|
||||
const parsed = Number(text(value))
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new ValidationError(`${field} must be a non-negative whole number`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function regionFromArn(arn: string | undefined): string | null {
|
||||
return arn?.split(':')[3] || null
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown, name: string): boolean {
|
||||
return typeof error === 'object' && error !== null && (error as {name?: string}).name === name
|
||||
}
|
||||
Reference in New Issue
Block a user