Files
floci-ui/packages/api/src/adapter-aws/AwsElastiCacheAdapter.ts
T
2026-10-05 16:03:05 -04:00

230 lines
11 KiB
TypeScript

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
}