feat: ElastiCache

This commit is contained in:
Zakaria
2026-10-05 16:03:05 -04:00
parent c0d3e5f402
commit c86b4554c0
27 changed files with 2454 additions and 5 deletions
@@ -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
}
+3
View File
@@ -28,6 +28,7 @@ import { KafkaConnectClient } from "@aws-sdk/client-kafkaconnect";
import { KinesisClient } from "@aws-sdk/client-kinesis";
import { SESv2Client } from "@aws-sdk/client-sesv2";
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -87,6 +88,7 @@ export type AwsClients = {
kinesis: KinesisClient;
sesv2: SESv2Client;
cloudwatch: CloudWatchClient;
elasticache: ElastiCacheClient;
};
export type AwsClientName = keyof AwsClients;
@@ -130,6 +132,7 @@ function buildClients(accountId: string): AwsClients {
kinesis: new KinesisClient(base),
sesv2: new SESv2Client(base),
cloudwatch: new CloudWatchClient(base),
elasticache: new ElastiCacheClient(base),
};
}
@@ -0,0 +1,15 @@
import {describe, expect, test} from 'bun:test'
import {awsElastiCacheSchema} from './elasticacheSchema'
describe('awsElastiCacheSchema', () => {
test('describes the generic ElastiCache management form and capabilities', () => {
const schema = awsElastiCacheSchema()
expect(schema).toMatchObject({cloud: 'aws', service: 'elasticache', actions: ['list', 'create', 'delete', 'inspect']})
expect(schema.fields.map((field) => field.name)).toEqual([
'kind', 'id', 'description', 'engine', 'nodeType', 'port', 'clusterMode', 'numNodeGroups', 'replicasPerNodeGroup', 'numCacheNodes',
])
expect(schema.fields.find((field) => field.name === 'engine')?.options?.map(({value}) => value)).toEqual(['redis', 'valkey', 'memcached'])
expect(schema.capabilities?.resourceActions?.every(({status}) => status === 'available')).toBe(true)
})
})
@@ -0,0 +1,57 @@
import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List caches', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create cache', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete cache', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect cache', enabled: true, status: 'available', runtimeRequired: true},
]
const fields: FieldSchema[] = [
{
name: 'kind', label: 'Resource type', type: 'select', required: true, defaultValue: 'replication-group', options: [
{label: 'Replication group', value: 'replication-group'},
{label: 'Cache cluster', value: 'cache-cluster'},
],
},
{name: 'id', label: 'Identifier', type: 'text', required: true, validation: {minLength: 1, maxLength: 40, message: 'Use a 1-40 character ElastiCache identifier.'}},
{name: 'description', label: 'Description', type: 'text', required: false, requiredWhen: {field: 'kind', equals: 'replication-group'}},
{
name: 'engine', label: 'Engine', type: 'select', required: true, defaultValue: 'redis', options: [
{label: 'Redis', value: 'redis'},
{label: 'Valkey', value: 'valkey'},
{label: 'Memcached', value: 'memcached'},
],
},
{name: 'nodeType', label: 'Node type', type: 'text', required: true, defaultValue: 'cache.t3.micro'},
{name: 'port', label: 'Port', type: 'text', required: false, description: 'Optional TCP port, from 1 through 65535.'},
{
name: 'clusterMode', label: 'Cluster mode topology', type: 'select', required: false, defaultValue: 'disabled', visibleWhen: {field: 'kind', values: ['replication-group']}, options: [
{label: 'Disabled: one node group', value: 'disabled'},
{label: 'Enabled: multiple node groups', value: 'enabled'},
],
},
{name: 'numNodeGroups', label: 'Node groups', type: 'text', required: false, defaultValue: '2', visibleWhen: {field: 'clusterMode', values: ['enabled']}},
{name: 'replicasPerNodeGroup', label: 'Replicas per node group', type: 'text', required: false, defaultValue: '0', visibleWhen: {field: 'clusterMode', values: ['enabled']}},
{name: 'numCacheNodes', label: 'Cache nodes', type: 'text', required: false, defaultValue: '1', visibleWhen: {field: 'kind', values: ['cache-cluster']}},
]
export function awsElastiCacheSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'elasticache',
displayName: 'Amazon ElastiCache',
fields,
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'Identifier'},
{name: 'type', label: 'Type'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'engine', label: 'Engine'},
{name: 'nodeType', label: 'Node type', path: 'metadata.nodeType'},
{name: 'endpoint', label: 'Endpoint', path: 'metadata.endpoint'},
],
}
}
@@ -130,6 +130,12 @@ export const SERVICE_CATALOG = {
group: 'Databases',
order: 20,
},
elasticache: {
displayName: 'ElastiCache',
iconKey: 'database',
group: 'Databases',
order: 30,
},
networking: {displayName: 'Networking', iconKey: 'networking', group: 'Networking', order: 10},
workflows: {
displayName: 'Workflows',
+2
View File
@@ -45,6 +45,7 @@ import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -92,6 +93,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsEcsAdapter(clients.ecs, clients.elbv2),
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
new AwsKinesisAdapter(clients.kinesis),
new AwsElastiCacheAdapter(clients.elasticache),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+2
View File
@@ -10,6 +10,7 @@ import clouds from "./routes/clouds";
import msk from "./routes/msk";
import mskConnect from "./routes/mskConnect";
import ses from "./routes/ses";
import elasticache from "./routes/elasticache";
const app = new Hono();
// Secrets Manager and KMS crypto routes handle sensitive values with server-side
@@ -49,6 +50,7 @@ app.route("/api/clouds", clouds);
app.route("/api/msk", msk);
app.route("/api/msk-connect", mskConnect);
app.route("/api/ses", ses);
app.route("/api/elasticache", elasticache);
// Serve static frontend files when public/ directory is present (production)
app.use("*", serveStatic({ root: "./public" }));
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { createElastiCacheRouter } from "./elasticache";
import { type ElastiCacheExplorer } from "../services/elasticacheExplorer";
import { type ElastiCacheManagementService } from "../services/elasticacheManagement";
function service(): ElastiCacheExplorer {
return {
listTargets: async () => [{ resourceId: "orders" }] as never,
ping: async () => ({ ping: "PONG" }) as never,
scan: async () => ({ cursor: "0", keys: ["order:1"] }),
keyDetails: async () => null,
setString: async () => ({ ok: true }),
deleteKey: async () => ({ deleted: true }),
};
}
describe("ElastiCache explorer routes", () => {
test("exposes ElastiCache user management routes", async () => {
const management = {listUsers: async () => [{UserId: "reader"}]} as unknown as ElastiCacheManagementService;
const app = createElastiCacheRouter(service(), management);
const response = await app.request("/users");
expect(response.status).toBe(200);
expect(await response.json()).toEqual([{UserId: "reader"}]);
});
test("proxies safe data explorer requests through the backend service", async () => {
const app = createElastiCacheRouter(service());
const response = await app.request("/keys/scan", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ resourceId: "orders", cursor: "0", count: 10 }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ cursor: "0", keys: ["order:1"] });
});
test("returns a clean validation error for malformed JSON", async () => {
const app = createElastiCacheRouter(service());
const response = await app.request("/connect", { method: "POST", body: "not json" });
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: { code: "VALIDATION_ERROR", message: "request body must be a JSON object" } });
});
});
+63
View File
@@ -0,0 +1,63 @@
import { Hono } from "hono";
import {
elasticacheExplorer,
ElastiCacheExplorerError,
type ElastiCacheExplorer,
} from "../services/elasticacheExplorer";
import {
elasticacheManagementService,
type CacheParameterGroupInput,
type CacheParameterInput,
type CacheSubnetGroupInput,
type ElastiCacheManagementService,
type ElastiCacheUserInput,
} from "../services/elasticacheManagement";
export function createElastiCacheRouter(service: ElastiCacheExplorer = elasticacheExplorer, management: ElastiCacheManagementService = elasticacheManagementService) {
const app = new Hono();
app.get("/targets", async (c) => respond(c, () => service.listTargets()));
app.post("/connect", async (c) => respond(c, async () => service.ping(await requestBody(c) as Parameters<ElastiCacheExplorer["ping"]>[0])));
app.post("/keys/scan", async (c) => respond(c, async () => service.scan(await requestBody(c) as Parameters<ElastiCacheExplorer["scan"]>[0])));
app.post("/keys/details", async (c) => respond(c, async () => service.keyDetails(await requestBody(c) as Parameters<ElastiCacheExplorer["keyDetails"]>[0])));
app.post("/keys/set", async (c) => respond(c, async () => service.setString(await requestBody(c) as Parameters<ElastiCacheExplorer["setString"]>[0])));
app.post("/keys/delete", async (c) => respond(c, async () => service.deleteKey(await requestBody(c) as Parameters<ElastiCacheExplorer["deleteKey"]>[0])));
app.get("/users", async (c) => c.json(await management.listUsers()));
app.post("/users", async (c) => c.json(await management.createUser(await c.req.json<ElastiCacheUserInput>()), 201));
app.patch("/users/:userId", async (c) => c.json(await management.modifyUser(c.req.param("userId"), await c.req.json<Omit<ElastiCacheUserInput, "userId" | "userName" | "engine">>())));
app.delete("/users/:userId", async (c) => c.json(await management.deleteUser(c.req.param("userId"))));
app.get("/subnet-groups", async (c) => c.json(await management.listSubnetGroups()));
app.post("/subnet-groups", async (c) => c.json(await management.createSubnetGroup(await c.req.json<CacheSubnetGroupInput>()), 201));
app.patch("/subnet-groups/:name", async (c) => c.json(await management.modifySubnetGroup(c.req.param("name"), await c.req.json<Omit<CacheSubnetGroupInput, "cacheSubnetGroupName">>())));
app.delete("/subnet-groups/:name", async (c) => c.json(await management.deleteSubnetGroup(c.req.param("name"))));
app.get("/parameter-groups", async (c) => c.json(await management.listParameterGroups()));
app.post("/parameter-groups", async (c) => c.json(await management.createParameterGroup(await c.req.json<CacheParameterGroupInput>()), 201));
app.delete("/parameter-groups/:name", async (c) => c.json(await management.deleteParameterGroup(c.req.param("name"))));
app.get("/parameter-groups/:name/parameters", async (c) => c.json(await management.listParameters(c.req.param("name"))));
app.put("/parameter-groups/:name/parameters", async (c) => c.json(await management.setParameters(c.req.param("name"), (await c.req.json<{ parameters: CacheParameterInput[] }>()).parameters)));
return app;
}
export default createElastiCacheRouter();
async function requestBody(c: { req: { json(): Promise<unknown> } }): Promise<Record<string, unknown>> {
try {
const body = await c.req.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
return body as Record<string, unknown>;
} catch {
throw new ElastiCacheExplorerError("request body must be a JSON object");
}
}
async function respond(c: { json(data: unknown, status?: number): Response }, operation: () => Promise<unknown>): Promise<Response> {
try {
return c.json(await operation());
} catch (error) {
if (error instanceof ElastiCacheExplorerError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status);
}
return c.json({ error: { code: "INTERNAL_ERROR", message: "ElastiCache explorer request failed" } }, 500);
}
}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import {
DescribeCacheClustersCommand,
DescribeReplicationGroupsCommand,
type ElastiCacheClient,
} from "@aws-sdk/client-elasticache";
import { createElastiCacheExplorer, ElastiCacheExplorerError, type RedisTarget } from "./elasticacheExplorer";
function explorerWith(client: Record<string, unknown>) {
const connections: Array<{ target: RedisTarget; credentials: { username: string; password: string }; timeoutMs: number }> = [];
const explorer = createElastiCacheExplorer({
async send(command: unknown) {
if (command instanceof DescribeReplicationGroupsCommand) {
return { ReplicationGroups: [{
ReplicationGroupId: "orders", Engine: "valkey",
ConfigurationEndpoint: { Address: "orders.cfg.local", Port: 6380 },
NodeGroups: [{ PrimaryEndpoint: { Address: "orders-primary.local", Port: 6379 } }],
}] };
}
if (command instanceof DescribeCacheClustersCommand) return { CacheClusters: [] };
throw new Error("unexpected command");
},
} as unknown as ElastiCacheClient, (target, credentials, timeoutMs) => {
connections.push({ target, credentials, timeoutMs });
return client as never;
});
return { explorer, connections };
}
describe("ElastiCache explorer", () => {
test("resolves the reported configuration endpoint and sends credentials only to the backend client", async () => {
const { explorer, connections } = explorerWith({
connect: async () => {}, close: async () => {}, ping: async () => "PONG",
});
await expect(explorer.ping({ resourceId: "orders", credentials: { username: "console", password: "secret" }, timeoutMs: 1_000 }))
.resolves.toMatchObject({ ping: "PONG", target: { endpoint: { address: "orders.cfg.local", port: 6380, kind: "configuration" } } });
expect(connections).toEqual([{ target: expect.objectContaining({ resourceId: "orders" }), credentials: { username: "console", password: "secret" }, timeoutMs: 1_000 }]);
});
test("reads supported key types with TTL and bounds collection reads", async () => {
const { explorer } = explorerWith({
connect: async () => {}, close: async () => {}, type: async () => "zset", ttl: async () => 30,
zRangeWithScores: async () => [{ value: "one", score: 1 }],
});
await expect(explorer.keyDetails({ resourceId: "orders", key: "ranking" })).resolves.toEqual({
key: "ranking", type: "zset", ttlSeconds: 30, value: [{ value: "one", score: 1 }],
});
});
test("rejects unsafe input before creating a TCP client", async () => {
const { explorer, connections } = explorerWith({});
await expect(explorer.scan({ resourceId: "orders", cursor: "bad", count: 10 })).rejects.toBeInstanceOf(ElastiCacheExplorerError);
await expect(explorer.setString({ resourceId: "orders", key: "a", value: "b", ttlSeconds: 0 })).rejects.toBeInstanceOf(ElastiCacheExplorerError);
expect(connections).toHaveLength(0);
});
});
@@ -0,0 +1,328 @@
import {
DescribeCacheClustersCommand,
DescribeReplicationGroupsCommand,
type CacheCluster,
type ElastiCacheClient,
type ReplicationGroup,
} from "@aws-sdk/client-elasticache";
import { createClient } from "redis";
import { awsClients } from "../aws";
const DEFAULT_TIMEOUT_MS = 5_000;
const MAX_TIMEOUT_MS = 10_000;
const MAX_SCAN_COUNT = 1_000;
const MAX_KEY_LENGTH = 512;
const MAX_VALUE_LENGTH = 1_000_000;
const MAX_COLLECTION_ITEMS = 1_000;
const MAX_TTL_SECONDS = 31_536_000;
const RESOURCE_ID = /^[a-z][a-z0-9-]{0,39}$/;
export type RedisCredentials = {
username?: string;
password?: string;
};
export type RedisTarget = {
resourceId: string;
resourceType: "replication-group" | "cache-cluster";
engine: "redis" | "valkey";
endpoint: { address: string; port: number; kind: "configuration" | "primary" | "node" };
};
export type ExplorerRequest = {
resourceId: string;
credentials?: RedisCredentials;
timeoutMs?: number;
};
type RedisDataClient = {
connect(): Promise<void>;
close(): Promise<void>;
ping(): Promise<string>;
scan(cursor: string, options: { MATCH?: string; COUNT?: number }): Promise<{ cursor: string; keys: string[] }>;
type(key: string): Promise<string>;
ttl(key: string): Promise<number>;
get(key: string): Promise<string | null>;
hGetAll(key: string): Promise<Record<string, string>>;
lRange(key: string, start: number, stop: number): Promise<string[]>;
sScan(key: string, cursor: string, options: { MATCH?: string; COUNT?: number }): Promise<{ cursor: string; members: string[] }>;
zRangeWithScores(key: string, start: number, stop: number): Promise<Array<{ value: string; score: number }>>;
set(key: string, value: string, options?: { EX: number }): Promise<string | null>;
del(key: string): Promise<number>;
};
type RedisClientFactory = (target: RedisTarget, credentials: Required<RedisCredentials>, timeoutMs: number) => RedisDataClient;
export class ElastiCacheExplorerError extends Error {
constructor(
message: string,
readonly status: 400 | 401 | 404 | 502 | 504 = 400,
readonly code = "VALIDATION_ERROR",
) {
super(message);
}
}
export interface ElastiCacheExplorer {
listTargets(): Promise<RedisTarget[]>;
ping(input: ExplorerRequest): Promise<{ target: RedisTarget; ping: string }>;
scan(input: ExplorerRequest & { cursor?: string; count?: number; match?: string }): Promise<{ cursor: string; keys: string[] }>;
keyDetails(input: ExplorerRequest & { key: string }): Promise<Record<string, unknown> | null>;
setString(input: ExplorerRequest & { key: string; value: string; ttlSeconds?: number }): Promise<{ ok: true }>;
deleteKey(input: ExplorerRequest & { key: string }): Promise<{ deleted: boolean }>;
}
export function createElastiCacheExplorer(
elasticache: Pick<ElastiCacheClient, "send"> = awsClients.elasticache,
createRedisClient: RedisClientFactory = nodeRedisClient,
): ElastiCacheExplorer {
async function target(resourceId: string): Promise<RedisTarget> {
validateResourceId(resourceId);
try {
const groupResponse = await elasticache.send(new DescribeReplicationGroupsCommand({ ReplicationGroupId: resourceId }));
const group = groupResponse.ReplicationGroups?.[0];
if (group) return replicationGroupTarget(group);
} catch (error) {
if (!isNotFound(error, "ReplicationGroupNotFoundFault")) throw error;
}
try {
const clusterResponse = await elasticache.send(new DescribeCacheClustersCommand({ CacheClusterId: resourceId, ShowCacheNodeInfo: true }));
const cluster = clusterResponse.CacheClusters?.[0];
if (cluster) return cacheClusterTarget(cluster);
} catch (error) {
if (!isNotFound(error, "CacheClusterNotFound")) throw error;
}
throw new ElastiCacheExplorerError(`ElastiCache resource ${resourceId} was not found`, 404, "RESOURCE_NOT_FOUND");
}
async function withClient<T>(input: ExplorerRequest, operation: (client: RedisDataClient) => Promise<T>): Promise<T> {
const timeoutMs = validateTimeout(input.timeoutMs);
const credentials = validateCredentials(input.credentials);
const resolvedTarget = await target(input.resourceId);
const client = createRedisClient(resolvedTarget, credentials, timeoutMs);
try {
await timed(client.connect(), timeoutMs);
return await timed(operation(client), timeoutMs);
} catch (error) {
throw mapRedisError(error);
} finally {
await client.close().catch(() => undefined);
}
}
return {
async listTargets(): Promise<RedisTarget[]> {
const [groups, clusters] = await Promise.all([allReplicationGroups(elasticache), allCacheClusters(elasticache)]);
return [...groups.flatMap(safeReplicationGroupTarget), ...clusters.flatMap(safeCacheClusterTarget)];
},
async ping(input) {
const resolvedTarget = await target(input.resourceId);
const ping = await withClient(input, (client) => client.ping());
return { target: resolvedTarget, ping };
},
async scan(input) {
const cursor = validateCursor(input.cursor);
const count = validateScanCount(input.count);
const match = optionalMatch(input.match);
return withClient(input, (client) => client.scan(cursor, { ...(match && { MATCH: match }), COUNT: count }));
},
async keyDetails(input) {
const key = validateKey(input.key);
return withClient(input, async (client) => {
const [type, ttlSeconds] = await Promise.all([client.type(key), client.ttl(key)]);
if (type === "none") return null;
const value = await keyValue(client, key, type);
return { key, type, ttlSeconds, value };
});
},
async setString(input) {
const key = validateKey(input.key);
if (typeof input.value !== "string" || input.value.length > MAX_VALUE_LENGTH) {
throw new ElastiCacheExplorerError(`value must be a string no longer than ${MAX_VALUE_LENGTH} characters`);
}
const ttlSeconds = optionalTtl(input.ttlSeconds);
await withClient(input, (client) => client.set(key, input.value, ttlSeconds === undefined ? undefined : { EX: ttlSeconds }));
return { ok: true as const };
},
async deleteKey(input) {
const key = validateKey(input.key);
const deleted = await withClient(input, (client) => client.del(key));
return { deleted: deleted > 0 };
},
};
}
export const elasticacheExplorer = createElastiCacheExplorer();
function nodeRedisClient(target: RedisTarget, credentials: Required<RedisCredentials>, timeoutMs: number): RedisDataClient {
return createClient({
socket: { host: target.endpoint.address, port: target.endpoint.port, connectTimeout: timeoutMs },
...(credentials.username ? { username: credentials.username, password: credentials.password } : credentials.password ? { password: credentials.password } : {}),
}) as unknown as RedisDataClient;
}
async function allReplicationGroups(client: Pick<ElastiCacheClient, "send">): Promise<ReplicationGroup[]> {
const groups: ReplicationGroup[] = [];
let marker: string | undefined;
do {
const response = await client.send(new DescribeReplicationGroupsCommand({ Marker: marker }));
groups.push(...(response.ReplicationGroups ?? []));
marker = response.Marker;
} while (marker);
return groups.filter((group) => group.Engine === "redis" || group.Engine === "valkey");
}
async function allCacheClusters(client: Pick<ElastiCacheClient, "send">): Promise<CacheCluster[]> {
const clusters: CacheCluster[] = [];
let marker: string | undefined;
do {
const response = await client.send(new DescribeCacheClustersCommand({ Marker: marker, ShowCacheNodeInfo: true }));
clusters.push(...(response.CacheClusters ?? []));
marker = response.Marker;
} while (marker);
return clusters.filter((cluster) => cluster.Engine === "redis" || cluster.Engine === "valkey");
}
function replicationGroupTarget(group: ReplicationGroup): RedisTarget {
const endpoint = endpointOf(group.ConfigurationEndpoint, "configuration") ?? endpointOf(group.NodeGroups?.[0]?.PrimaryEndpoint, "primary");
return targetFromEndpoint(group.ReplicationGroupId, group.Engine, "replication-group", endpoint);
}
function cacheClusterTarget(cluster: CacheCluster): RedisTarget {
const endpoint = endpointOf(cluster.ConfigurationEndpoint, "configuration") ?? endpointOf(cluster.CacheNodes?.[0]?.Endpoint, "node");
return targetFromEndpoint(cluster.CacheClusterId, cluster.Engine, "cache-cluster", endpoint);
}
function targetFromEndpoint(
resourceId: string | undefined,
engine: string | undefined,
resourceType: RedisTarget["resourceType"],
endpoint: RedisTarget["endpoint"] | undefined,
): RedisTarget {
if (!resourceId || (engine !== "redis" && engine !== "valkey")) {
throw new ElastiCacheExplorerError("The selected resource is not a Redis or Valkey cache", 400, "UNSUPPORTED_ENGINE");
}
if (!endpoint) throw new ElastiCacheExplorerError(`ElastiCache resource ${resourceId} has no data-plane endpoint`, 502, "ENDPOINT_UNAVAILABLE");
return { resourceId, resourceType, engine, endpoint };
}
function endpointOf(endpoint: { Address?: string; Port?: number } | undefined, kind: RedisTarget["endpoint"]["kind"]): RedisTarget["endpoint"] | undefined {
const port = endpoint?.Port;
if (!endpoint?.Address || !Number.isInteger(port) || port === undefined || port < 1 || port > 65535) return undefined;
return { address: endpoint.Address, port, kind };
}
async function keyValue(client: RedisDataClient, key: string, type: string): Promise<unknown> {
switch (type) {
case "string": return client.get(key);
case "hash": return client.hGetAll(key);
case "list": return client.lRange(key, 0, MAX_COLLECTION_ITEMS - 1);
case "set": return (await client.sScan(key, "0", { COUNT: MAX_COLLECTION_ITEMS })).members;
case "zset": return client.zRangeWithScores(key, 0, MAX_COLLECTION_ITEMS - 1);
default: throw new ElastiCacheExplorerError(`Key ${key} has unsupported type ${type}`, 400, "UNSUPPORTED_KEY_TYPE");
}
}
function validateResourceId(value: unknown): asserts value is string {
if (typeof value !== "string" || !RESOURCE_ID.test(value)) throw new ElastiCacheExplorerError("resourceId must be a valid ElastiCache identifier");
}
function validateKey(value: unknown): string {
if (typeof value !== "string" || !value || value.length > MAX_KEY_LENGTH || /[\u0000\r\n]/.test(value)) {
throw new ElastiCacheExplorerError(`key must be a non-empty string up to ${MAX_KEY_LENGTH} characters without control characters`);
}
return value;
}
function validateCursor(value: unknown): string {
if (value === undefined) return "0";
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new ElastiCacheExplorerError("cursor must be a non-negative integer string");
return value;
}
function validateScanCount(value: unknown): number {
if (value === undefined) return 100;
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_SCAN_COUNT) {
throw new ElastiCacheExplorerError(`count must be an integer from 1 to ${MAX_SCAN_COUNT}`);
}
return value as number;
}
function optionalMatch(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || value.length > MAX_KEY_LENGTH || /[\u0000\r\n]/.test(value)) {
throw new ElastiCacheExplorerError(`match must be a string up to ${MAX_KEY_LENGTH} characters without control characters`);
}
return value;
}
function optionalTtl(value: unknown): number | undefined {
if (value === undefined) return undefined;
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_TTL_SECONDS) {
throw new ElastiCacheExplorerError(`ttlSeconds must be an integer from 1 to ${MAX_TTL_SECONDS}`);
}
return value as number;
}
function validateTimeout(value: unknown): number {
if (value === undefined) return DEFAULT_TIMEOUT_MS;
if (!Number.isInteger(value) || (value as number) < 100 || (value as number) > MAX_TIMEOUT_MS) {
throw new ElastiCacheExplorerError(`timeoutMs must be an integer from 100 to ${MAX_TIMEOUT_MS}`);
}
return value as number;
}
function validateCredentials(value: RedisCredentials | undefined): Required<RedisCredentials> {
const username = value?.username ?? "";
const password = value?.password ?? "";
if (typeof username !== "string" || typeof password !== "string" || username.length > 128 || password.length > 1_024) {
throw new ElastiCacheExplorerError("credentials must contain string username and password values within their limits");
}
if (username && !password) throw new ElastiCacheExplorerError("password is required when username is provided");
return { username, password };
}
function timed<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new ElastiCacheExplorerError(`Redis operation timed out after ${timeoutMs}ms`, 504, "REDIS_TIMEOUT")), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
function mapRedisError(error: unknown): ElastiCacheExplorerError {
if (error instanceof ElastiCacheExplorerError) return error;
const message = error instanceof Error ? error.message : "Redis data-plane request failed";
if (/NOAUTH|WRONGPASS|invalid username-password/i.test(message)) return new ElastiCacheExplorerError("Redis authentication failed", 401, "REDIS_AUTH_FAILED");
return new ElastiCacheExplorerError(`Redis data-plane request failed: ${message}`, 502, "REDIS_CONNECTION_FAILED");
}
function safeReplicationGroupTarget(group: ReplicationGroup): RedisTarget[] {
try {
return [replicationGroupTarget(group)];
} catch (error) {
if (error instanceof ElastiCacheExplorerError && error.code === "ENDPOINT_UNAVAILABLE") return [];
throw error;
}
}
function safeCacheClusterTarget(cluster: CacheCluster): RedisTarget[] {
try {
return [cacheClusterTarget(cluster)];
} catch (error) {
if (error instanceof ElastiCacheExplorerError && error.code === "ENDPOINT_UNAVAILABLE") return [];
throw error;
}
}
function isNotFound(error: unknown, name: string): boolean {
return typeof error === "object" && error !== null && (error as { name?: string }).name === name;
}
@@ -0,0 +1,42 @@
import {describe, expect, test} from "bun:test";
import {
CreateCacheParameterGroupCommand,
CreateCacheSubnetGroupCommand,
CreateUserCommand,
DescribeUsersCommand,
ModifyCacheParameterGroupCommand,
ModifyCacheSubnetGroupCommand,
ModifyUserCommand,
type ElastiCacheClient,
} from "@aws-sdk/client-elasticache";
import {createElastiCacheManagementService} from "./elasticacheManagement";
describe("ElastiCache management service", () => {
test("uses native SDK commands for user authentication modes and access strings", async () => {
const commands: unknown[] = [];
const service = createElastiCacheManagementService({send: async (command: unknown) => {commands.push(command); return {Users: [{UserId: "reader"}], Marker: undefined};}} as ElastiCacheClient);
await service.createUser({userId: "reader", userName: "reader", engine: "redis", accessString: "on ~orders:* +get", authenticationMode: "password", passwords: ["secret"]});
await service.modifyUser("reader", {accessString: "on ~* +@read", authenticationMode: "iam"});
await service.listUsers();
expect((commands[0] as CreateUserCommand).input).toEqual({UserId: "reader", UserName: "reader", Engine: "redis", AccessString: "on ~orders:* +get", AuthenticationMode: {Type: "password", Passwords: ["secret"]}});
expect((commands[1] as ModifyUserCommand).input).toEqual({UserId: "reader", AccessString: "on ~* +@read", AuthenticationMode: {Type: "iam"}});
expect(commands[2]).toBeInstanceOf(DescribeUsersCommand);
});
test("uses subnet and parameter group SDK contracts", async () => {
const commands: unknown[] = [];
const service = createElastiCacheManagementService({send: async (command: unknown) => {commands.push(command); return {};}} as ElastiCacheClient);
await service.createSubnetGroup({cacheSubnetGroupName: "app", cacheSubnetGroupDescription: "Application subnets", subnetIds: ["subnet-a", "subnet-b"]});
await service.modifySubnetGroup("app", {cacheSubnetGroupDescription: "Updated", subnetIds: ["subnet-b"]});
await service.createParameterGroup({cacheParameterGroupName: "app-redis", cacheParameterGroupFamily: "redis7", description: "Application Redis"});
await service.setParameters("app-redis", [{parameterName: "maxmemory-policy", parameterValue: "allkeys-lru"}]);
expect((commands[0] as CreateCacheSubnetGroupCommand).input).toEqual({CacheSubnetGroupName: "app", CacheSubnetGroupDescription: "Application subnets", SubnetIds: ["subnet-a", "subnet-b"]});
expect((commands[1] as ModifyCacheSubnetGroupCommand).input).toEqual({CacheSubnetGroupName: "app", CacheSubnetGroupDescription: "Updated", SubnetIds: ["subnet-b"]});
expect(commands[2]).toBeInstanceOf(CreateCacheParameterGroupCommand);
expect((commands[3] as ModifyCacheParameterGroupCommand).input).toEqual({CacheParameterGroupName: "app-redis", ParameterNameValues: [{ParameterName: "maxmemory-policy", ParameterValue: "allkeys-lru"}]});
});
});
@@ -0,0 +1,132 @@
import {
CreateCacheParameterGroupCommand,
CreateCacheSubnetGroupCommand,
CreateUserCommand,
DeleteCacheParameterGroupCommand,
DeleteCacheSubnetGroupCommand,
DeleteUserCommand,
DescribeCacheParameterGroupsCommand,
DescribeCacheParametersCommand,
DescribeCacheSubnetGroupsCommand,
DescribeUsersCommand,
ModifyCacheParameterGroupCommand,
ModifyCacheSubnetGroupCommand,
ModifyUserCommand,
type ElastiCacheClient,
} from "@aws-sdk/client-elasticache";
import { awsClients } from "../aws";
export type ElastiCacheUserInput = {
userId: string;
userName: string;
engine: "redis" | "valkey";
accessString: string;
authenticationMode: "password" | "no-password-required" | "iam";
passwords?: string[];
};
export type CacheSubnetGroupInput = {
cacheSubnetGroupName: string;
cacheSubnetGroupDescription: string;
subnetIds: string[];
};
export type CacheParameterGroupInput = {
cacheParameterGroupName: string;
cacheParameterGroupFamily: string;
description: string;
};
export type CacheParameterInput = {
parameterName: string;
parameterValue: string;
};
export function createElastiCacheManagementService(elasticache: Pick<ElastiCacheClient, "send"> = awsClients.elasticache) {
return {
async listUsers() {
return collect((Marker) => elasticache.send(new DescribeUsersCommand({ Marker })) as Promise<{ Users?: unknown[]; Marker?: string }>, "Users");
},
async createUser(input: ElastiCacheUserInput) {
return elasticache.send(new CreateUserCommand({
UserId: input.userId,
UserName: input.userName,
Engine: input.engine,
...userInput(input),
}));
},
async modifyUser(userId: string, input: Omit<ElastiCacheUserInput, "userId" | "userName" | "engine">) {
return elasticache.send(new ModifyUserCommand({ UserId: userId, ...userInput(input) }));
},
async deleteUser(userId: string) {
return elasticache.send(new DeleteUserCommand({ UserId: userId }));
},
async listSubnetGroups() {
return collect((Marker) => elasticache.send(new DescribeCacheSubnetGroupsCommand({ Marker })) as Promise<{ CacheSubnetGroups?: unknown[]; Marker?: string }>, "CacheSubnetGroups");
},
async createSubnetGroup(input: CacheSubnetGroupInput) {
return elasticache.send(new CreateCacheSubnetGroupCommand({
CacheSubnetGroupName: input.cacheSubnetGroupName,
CacheSubnetGroupDescription: input.cacheSubnetGroupDescription,
SubnetIds: input.subnetIds,
}));
},
async modifySubnetGroup(name: string, input: Omit<CacheSubnetGroupInput, "cacheSubnetGroupName">) {
return elasticache.send(new ModifyCacheSubnetGroupCommand({
CacheSubnetGroupName: name,
CacheSubnetGroupDescription: input.cacheSubnetGroupDescription,
SubnetIds: input.subnetIds,
}));
},
async deleteSubnetGroup(name: string) {
return elasticache.send(new DeleteCacheSubnetGroupCommand({ CacheSubnetGroupName: name }));
},
async listParameterGroups() {
return collect((Marker) => elasticache.send(new DescribeCacheParameterGroupsCommand({ Marker })) as Promise<{ CacheParameterGroups?: unknown[]; Marker?: string }>, "CacheParameterGroups");
},
async createParameterGroup(input: CacheParameterGroupInput) {
return elasticache.send(new CreateCacheParameterGroupCommand({
CacheParameterGroupName: input.cacheParameterGroupName,
CacheParameterGroupFamily: input.cacheParameterGroupFamily,
Description: input.description,
}));
},
async deleteParameterGroup(name: string) {
return elasticache.send(new DeleteCacheParameterGroupCommand({ CacheParameterGroupName: name }));
},
async listParameters(name: string) {
return collect((Marker) => elasticache.send(new DescribeCacheParametersCommand({ CacheParameterGroupName: name, Marker })) as Promise<{ Parameters?: unknown[]; Marker?: string }>, "Parameters");
},
async setParameters(name: string, parameters: CacheParameterInput[]) {
return elasticache.send(new ModifyCacheParameterGroupCommand({
CacheParameterGroupName: name,
ParameterNameValues: parameters.map((parameter) => ({
ParameterName: parameter.parameterName,
ParameterValue: parameter.parameterValue,
})),
}));
},
};
}
export type ElastiCacheManagementService = ReturnType<typeof createElastiCacheManagementService>;
export const elasticacheManagementService = createElastiCacheManagementService();
function userInput(input: Pick<ElastiCacheUserInput, "accessString" | "authenticationMode" | "passwords">) {
return {
AccessString: input.accessString,
AuthenticationMode: { Type: input.authenticationMode, ...(input.passwords?.length && { Passwords: input.passwords }) },
};
}
async function collect<T>(request: (marker: string | undefined) => Promise<{ Marker?: string } & Record<string, T[] | string | undefined>>, key: string): Promise<T[]> {
const items: T[] = [];
let marker: string | undefined;
do {
const response = await request(marker);
const page = response[key];
if (Array.isArray(page)) items.push(...page as T[]);
marker = response.Marker;
} while (marker);
return items;
}