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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }