feat: ElastiCache
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user