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
+801 -2
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -24,6 +24,7 @@
"@aws-sdk/client-ecs": "^3.1076.0", "@aws-sdk/client-ecs": "^3.1076.0",
"@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-elasticache": "^3.1146.0",
"@aws-sdk/client-eventbridge": "^3.1076.0", "@aws-sdk/client-eventbridge": "^3.1076.0",
"@aws-sdk/client-iam": "^3.1076.0", "@aws-sdk/client-iam": "^3.1076.0",
"@aws-sdk/client-kafka": "^3.1146.0", "@aws-sdk/client-kafka": "^3.1146.0",
@@ -45,7 +46,8 @@
"hono": "^4.12.27", "hono": "^4.12.27",
"kafkajs": "^2.2.4", "kafkajs": "^2.2.4",
"mssql": "^12.7.0", "mssql": "^12.7.0",
"pg": "^8.22.0" "pg": "^8.22.0",
"redis": "^6.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.2", "@types/bun": "^1.3.2",
@@ -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 { KinesisClient } from "@aws-sdk/client-kinesis";
import { SESv2Client } from "@aws-sdk/client-sesv2"; import { SESv2Client } from "@aws-sdk/client-sesv2";
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -87,6 +88,7 @@ export type AwsClients = {
kinesis: KinesisClient; kinesis: KinesisClient;
sesv2: SESv2Client; sesv2: SESv2Client;
cloudwatch: CloudWatchClient; cloudwatch: CloudWatchClient;
elasticache: ElastiCacheClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -130,6 +132,7 @@ function buildClients(accountId: string): AwsClients {
kinesis: new KinesisClient(base), kinesis: new KinesisClient(base),
sesv2: new SESv2Client(base), sesv2: new SESv2Client(base),
cloudwatch: new CloudWatchClient(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', group: 'Databases',
order: 20, order: 20,
}, },
elasticache: {
displayName: 'ElastiCache',
iconKey: 'database',
group: 'Databases',
order: 30,
},
networking: {displayName: 'Networking', iconKey: 'networking', group: 'Networking', order: 10}, networking: {displayName: 'Networking', iconKey: 'networking', group: 'Networking', order: 10},
workflows: { workflows: {
displayName: 'Workflows', displayName: 'Workflows',
+2
View File
@@ -45,6 +45,7 @@ import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter' import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter' import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter' import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -92,6 +93,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsEcsAdapter(clients.ecs, clients.elbv2), new AwsEcsAdapter(clients.ecs, clients.elbv2),
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)), new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
new AwsKinesisAdapter(clients.kinesis), new AwsKinesisAdapter(clients.kinesis),
new AwsElastiCacheAdapter(clients.elasticache),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+2
View File
@@ -10,6 +10,7 @@ import clouds from "./routes/clouds";
import msk from "./routes/msk"; import msk from "./routes/msk";
import mskConnect from "./routes/mskConnect"; import mskConnect from "./routes/mskConnect";
import ses from "./routes/ses"; import ses from "./routes/ses";
import elasticache from "./routes/elasticache";
const app = new Hono(); const app = new Hono();
// Secrets Manager and KMS crypto routes handle sensitive values with server-side // 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", msk);
app.route("/api/msk-connect", mskConnect); app.route("/api/msk-connect", mskConnect);
app.route("/api/ses", ses); app.route("/api/ses", ses);
app.route("/api/elasticache", elasticache);
// Serve static frontend files when public/ directory is present (production) // Serve static frontend files when public/ directory is present (production)
app.use("*", serveStatic({ root: "./public" })); 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;
}
@@ -0,0 +1,85 @@
import {apiClient} from './api'
import {HttpError} from './HttpClient'
export interface RedisCredentials {
username?: string
password?: string
}
export interface RedisTarget {
resourceId: string
resourceType: 'replication-group' | 'cache-cluster'
engine: 'redis' | 'valkey'
endpoint: {address: string; port: number; kind: 'configuration' | 'primary' | 'node'}
}
export interface RedisExplorerRequest {
resourceId: string
credentials?: RedisCredentials
}
export interface RedisKeyDetails {
key: string
type: string
ttlSeconds: number
value: unknown
}
export type ElastiCacheUser = { UserId?: string; UserName?: string; Engine?: string; AccessString?: string; Authentication?: { Type?: string } }
export type ElastiCacheSubnetGroup = { CacheSubnetGroupName?: string; CacheSubnetGroupDescription?: string; Subnets?: Array<{ SubnetIdentifier?: string }> }
export type ElastiCacheParameterGroup = { CacheParameterGroupName?: string; CacheParameterGroupFamily?: string; Description?: string }
export type ElastiCacheParameter = { ParameterName?: string; ParameterValue?: string; IsModifiable?: boolean; Description?: string; DataType?: string }
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 async function connectRedis(input: RedisExplorerRequest): Promise<{target: RedisTarget; ping: string}> {
return request('/elasticache/connect', input)
}
export async function scanRedisKeys(input: RedisExplorerRequest & {cursor?: string; count?: number; match?: string}): Promise<{cursor: string; keys: string[]}> {
return request('/elasticache/keys/scan', input)
}
export async function getRedisKeyDetails(input: RedisExplorerRequest & {key: string}): Promise<RedisKeyDetails | null> {
return request('/elasticache/keys/details', input)
}
export async function setRedisString(input: RedisExplorerRequest & {key: string; value: string; ttlSeconds?: number}): Promise<{ok: true}> {
return request('/elasticache/keys/set', input)
}
export async function deleteRedisKey(input: RedisExplorerRequest & {key: string}): Promise<{deleted: boolean}> {
return request('/elasticache/keys/delete', input)
}
export async function listElastiCacheUsers(): Promise<ElastiCacheUser[]> { return get('/elasticache/users') }
export async function createElastiCacheUser(input: ElastiCacheUserInput): Promise<unknown> { return request('/elasticache/users', input) }
export async function modifyElastiCacheUser(userId: string, input: Omit<ElastiCacheUserInput, 'userId' | 'userName' | 'engine'>): Promise<unknown> { return patch(`/elasticache/users/${encodeURIComponent(userId)}`, input) }
export async function deleteElastiCacheUser(userId: string): Promise<unknown> { return remove(`/elasticache/users/${encodeURIComponent(userId)}`) }
export async function listCacheSubnetGroups(): Promise<ElastiCacheSubnetGroup[]> { return get('/elasticache/subnet-groups') }
export async function createCacheSubnetGroup(input: CacheSubnetGroupInput): Promise<unknown> { return request('/elasticache/subnet-groups', input) }
export async function modifyCacheSubnetGroup(name: string, input: Omit<CacheSubnetGroupInput, 'cacheSubnetGroupName'>): Promise<unknown> { return patch(`/elasticache/subnet-groups/${encodeURIComponent(name)}`, input) }
export async function deleteCacheSubnetGroup(name: string): Promise<unknown> { return remove(`/elasticache/subnet-groups/${encodeURIComponent(name)}`) }
export async function listCacheParameterGroups(): Promise<ElastiCacheParameterGroup[]> { return get('/elasticache/parameter-groups') }
export async function createCacheParameterGroup(input: CacheParameterGroupInput): Promise<unknown> { return request('/elasticache/parameter-groups', input) }
export async function deleteCacheParameterGroup(name: string): Promise<unknown> { return remove(`/elasticache/parameter-groups/${encodeURIComponent(name)}`) }
export async function listCacheParameters(name: string): Promise<ElastiCacheParameter[]> { return get(`/elasticache/parameter-groups/${encodeURIComponent(name)}/parameters`) }
export async function setCacheParameters(name: string, parameters: Array<{parameterName: string; parameterValue: string}>): Promise<unknown> { return put(`/elasticache/parameter-groups/${encodeURIComponent(name)}/parameters`, {parameters}) }
async function request<TResponse, TBody>(path: string, body: TBody): Promise<TResponse> {
try {
return (await apiClient.post<TResponse, TBody>(path, body)).data
} catch (error) {
if (error instanceof HttpError) {
const apiError = error.body as {error?: {code?: unknown; message?: unknown}}
if (typeof apiError.error?.message === 'string') throw Object.assign(new Error(apiError.error.message), {cause: error})
}
throw error
}
}
async function get<T>(path: string): Promise<T> { return (await apiClient.get<T>(path)).data }
async function patch<T, B>(path: string, body: B): Promise<T> { return (await apiClient.patch<T, B>(path, body)).data }
async function put<T, B>(path: string, body: B): Promise<T> { return (await apiClient.put<T, B>(path, body)).data }
async function remove<T>(path: string): Promise<T> { return (await apiClient.delete<T>(path)).data }
@@ -72,6 +72,7 @@ import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPan
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel"; import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {KinesisPanel} from "@/components/KinesisPanel"; import {KinesisPanel} from "@/components/KinesisPanel";
import {SesConsolePanel} from '@/components/SesConsolePanel' import {SesConsolePanel} from '@/components/SesConsolePanel'
import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel'
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio"; import {workflowStudioPath} from "@/lib/workflowStudio";
import {useNavigate, useSearchParams} from "react-router-dom"; import {useNavigate, useSearchParams} from "react-router-dom";
@@ -1040,7 +1041,8 @@ export function DynamicResourceView({
{service === "codedeploy" && cloud === "aws" && ( {service === "codedeploy" && cloud === "aws" && (
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />} {service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
{service === "elasticache" && cloud === "aws" && <ElastiCacheManagementPanel runtimeReachable={canUseRuntime} />}
{editingResource && schema.updateFields && ( {editingResource && schema.updateFields && (
<div <div
className="modal-overlay" className="modal-overlay"
@@ -0,0 +1,35 @@
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {render, screen, waitFor} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {beforeEach, expect, test, vi} from "vitest";
import {ElastiCacheManagementPanel} from "./ElastiCacheManagementPanel";
const api = vi.hoisted(() => ({
createCacheParameterGroup: vi.fn(), createCacheSubnetGroup: vi.fn(), createElastiCacheUser: vi.fn(), deleteCacheParameterGroup: vi.fn(), deleteCacheSubnetGroup: vi.fn(), deleteElastiCacheUser: vi.fn(), listCacheParameterGroups: vi.fn(), listCacheParameters: vi.fn(), listCacheSubnetGroups: vi.fn(), listElastiCacheUsers: vi.fn(), modifyCacheSubnetGroup: vi.fn(), modifyElastiCacheUser: vi.fn(), setCacheParameters: vi.fn(),
}));
const cloud = vi.hoisted(() => ({getLoadBalancingDependencies: vi.fn()}));
vi.mock("@/api/elasticacheClient", () => api);
vi.mock("@/api/cloudProxyClient", () => cloud);
beforeEach(() => {
vi.clearAllMocks();
api.listElastiCacheUsers.mockResolvedValue([]);
api.listCacheSubnetGroups.mockResolvedValue([]);
api.listCacheParameterGroups.mockResolvedValue([]);
cloud.getLoadBalancingDependencies.mockResolvedValue({subnets: [{id: "subnet-a", vpcId: "vpc-a", availabilityZone: "us-east-1a", cidrBlock: "10.0.1.0/24"}]});
});
test("creates cache subnet groups from cloud dependency subnet selections", async () => {
const user = userEvent.setup();
render(<QueryClientProvider client={new QueryClient({defaultOptions: {queries: {retry: false}}})}><ElastiCacheManagementPanel runtimeReachable /></QueryClientProvider>);
await user.click(screen.getByRole("button", {name: "subnet groups"}));
await screen.findByRole("option", {name: /subnet-a/});
await user.type(screen.getByLabelText("Name"), "application");
await user.type(screen.getByLabelText("Description"), "Application subnets");
await user.selectOptions(screen.getByLabelText("EC2 subnets"), "subnet-a");
await user.click(screen.getByRole("button", {name: "Create subnet group"}));
await waitFor(() => expect(api.createCacheSubnetGroup).toHaveBeenCalledWith({cacheSubnetGroupName: "application", cacheSubnetGroupDescription: "Application subnets", subnetIds: ["subnet-a"]}));
expect(cloud.getLoadBalancingDependencies).toHaveBeenCalled();
});
@@ -0,0 +1,54 @@
import {useEffect, useState} from "react";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {Plus, RefreshCw, Trash2} from "lucide-react";
import {getLoadBalancingDependencies} from "@/api/cloudProxyClient";
import {
createCacheParameterGroup, createCacheSubnetGroup, createElastiCacheUser, deleteCacheParameterGroup, deleteCacheSubnetGroup,
deleteElastiCacheUser, listCacheParameterGroups, listCacheParameters, listCacheSubnetGroups, listElastiCacheUsers,
modifyCacheSubnetGroup, modifyElastiCacheUser, setCacheParameters,
} from "@/api/elasticacheClient";
type Tab = "users" | "subnet-groups" | "parameter-groups";
export function ElastiCacheManagementPanel({runtimeReachable}: {runtimeReachable: boolean}) {
const [tab, setTab] = useState<Tab>("users");
return <section className="table-panel" style={{marginTop: 16}}>
<div className="dynamic-stage-header"><div><p className="eyebrow">ElastiCache management</p><h3>Users and configuration groups</h3></div></div>
<div className="drawer-tabs">
{(["users", "subnet-groups", "parameter-groups"] as Tab[]).map((item) => <button className={`drawer-tab ${tab === item ? "active" : ""}`} type="button" key={item} onClick={() => setTab(item)}>{item.replace("-", " ")}</button>)}
</div>
{tab === "users" && <Users runtimeReachable={runtimeReachable}/>} {tab === "subnet-groups" && <SubnetGroups runtimeReachable={runtimeReachable}/>} {tab === "parameter-groups" && <ParameterGroups runtimeReachable={runtimeReachable}/>}
</section>;
}
function Users({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient(); const [userId, setUserId] = useState(""); const [userName, setUserName] = useState(""); const [engine, setEngine] = useState<"redis" | "valkey">("redis"); const [accessString, setAccessString] = useState("on ~* +@all"); const [mode, setMode] = useState<"password" | "no-password-required" | "iam">("no-password-required"); const [passwords, setPasswords] = useState("");
const query = useQuery({queryKey: ["elasticache", "users"], queryFn: listElastiCacheUsers, enabled: runtimeReachable});
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "users"]});
const create = useMutation({mutationFn: () => createElastiCacheUser({userId: userId.trim(), userName: userName.trim(), engine, accessString: accessString.trim(), authenticationMode: mode, ...(passwords.trim() && {passwords: passwords.split("\n").map((value) => value.trim()).filter(Boolean)})}), onSuccess: () => {setUserId(""); setUserName(""); setPasswords(""); refresh();}});
const remove = useMutation({mutationFn: deleteElastiCacheUser, onSuccess: refresh});
const modify = useMutation({mutationFn: (id: string) => modifyElastiCacheUser(id, {accessString: accessString.trim(), authenticationMode: mode, ...(passwords.trim() && {passwords: passwords.split("\n").map((value) => value.trim()).filter(Boolean)})}), onSuccess: refresh});
return <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); create.mutate();}}><label className="dynamic-field"><span>User ID</span><input className="input" value={userId} onChange={(e) => setUserId(e.target.value)} required/></label><label className="dynamic-field"><span>User name</span><input className="input" value={userName} onChange={(e) => setUserName(e.target.value)} required/></label><label className="dynamic-field"><span>Engine</span><select className="input" value={engine} onChange={(e) => setEngine(e.target.value as typeof engine)}><option value="redis">Redis</option><option value="valkey">Valkey</option></select></label><UserSettings accessString={accessString} setAccessString={setAccessString} mode={mode} setMode={setMode} passwords={passwords} setPasswords={setPasswords}/><button className="button primary" type="submit" disabled={!runtimeReachable || create.isPending}><Plus size={13}/>Create user</button></form><ActionError error={create.error ?? remove.error ?? modify.error}/><ListHeader onRefresh={() => query.refetch()} loading={query.isFetching}/>{query.data?.map((user) => <div className="apigateway-route-item" key={user.UserId}><div className="apigateway-route-select"><code>{user.UserName ?? user.UserId}</code><small>{user.Engine} · {user.Authentication?.Type ?? "authentication unset"} · {user.AccessString}</small></div><button className="button compact" type="button" onClick={() => user.UserId && modify.mutate(user.UserId)} disabled={!runtimeReachable || modify.isPending}>Apply settings</button><button className="icon-btn danger" type="button" aria-label={`Delete user ${user.UserId}`} onClick={() => user.UserId && remove.mutate(user.UserId)}><Trash2 size={14}/></button></div>)}</>;
}
function UserSettings(props: {accessString: string; setAccessString: (value: string) => void; mode: "password" | "no-password-required" | "iam"; setMode: (value: "password" | "no-password-required" | "iam") => void; passwords: string; setPasswords: (value: string) => void}) { return <><label className="dynamic-field"><span>Access string</span><input className="input" value={props.accessString} onChange={(e) => props.setAccessString(e.target.value)} required/></label><label className="dynamic-field"><span>Authentication</span><select className="input" value={props.mode} onChange={(e) => props.setMode(e.target.value as typeof props.mode)}><option value="no-password-required">No password</option><option value="password">Password</option><option value="iam">IAM</option></select></label>{props.mode === "password" && <label className="dynamic-field"><span>Passwords</span><textarea className="textarea" value={props.passwords} onChange={(e) => props.setPasswords(e.target.value)} required/><small>One password per line.</small></label>}</> }
function SubnetGroups({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [subnets, setSubnets] = useState<string[]>([]);
const groups = useQuery({queryKey: ["elasticache", "subnet-groups"], queryFn: listCacheSubnetGroups, enabled: runtimeReachable});
const cloudSubnets = useQuery({queryKey: ["cloud-dependencies", "aws", "subnets"], queryFn: () => getLoadBalancingDependencies(), enabled: runtimeReachable});
const subnetResources = cloudSubnets.data?.subnets ?? [];
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "subnet-groups"]});
const create = useMutation({mutationFn: () => createCacheSubnetGroup({cacheSubnetGroupName: name.trim(), cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: () => {setName(""); setDescription(""); setSubnets([]); refresh();}}); const remove = useMutation({mutationFn: deleteCacheSubnetGroup, onSuccess: refresh}); const modify = useMutation({mutationFn: (group: string) => modifyCacheSubnetGroup(group, {cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: refresh});
return <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); create.mutate();}}><label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(e) => setName(e.target.value)} required/></label><label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} required/></label><label className="dynamic-field"><span>EC2 subnets</span><select className="input" aria-label="EC2 subnets" multiple value={subnets} onChange={(e) => setSubnets(Array.from(e.currentTarget.selectedOptions, (option) => option.value))} required>{subnetResources.map((subnet) => <option key={subnet.id} value={subnet.id}>{subnet.id} ({subnet.availabilityZone})</option>)}</select><small>Subnets are loaded from AWS cloud resource dependencies.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || !subnets.length || create.isPending}><Plus size={13}/>Create subnet group</button></form><ActionError error={create.error ?? remove.error ?? modify.error}/><ListHeader onRefresh={() => groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) => <div className="apigateway-route-item" key={group.CacheSubnetGroupName}><div className="apigateway-route-select"><code>{group.CacheSubnetGroupName}</code><small>{group.CacheSubnetGroupDescription} · {group.Subnets?.map((subnet) => subnet.SubnetIdentifier).join(", ")}</small></div><button className="button compact" type="button" disabled={modify.isPending} onClick={() => group.CacheSubnetGroupName && modify.mutate(group.CacheSubnetGroupName)}>Apply selected subnets</button><button className="icon-btn danger" type="button" aria-label={`Delete subnet group ${group.CacheSubnetGroupName}`} onClick={() => group.CacheSubnetGroupName && remove.mutate(group.CacheSubnetGroupName)}><Trash2 size={14}/></button></div>)}</>;
}
function ParameterGroups({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient(); const [name, setName] = useState(""); const [family, setFamily] = useState("redis7"); const [description, setDescription] = useState(""); const [selected, setSelected] = useState(""); const [parameterName, setParameterName] = useState(""); const [parameterValue, setParameterValue] = useState("");
const groups = useQuery({queryKey: ["elasticache", "parameter-groups"], queryFn: listCacheParameterGroups, enabled: runtimeReachable}); const parameters = useQuery({queryKey: ["elasticache", "parameters", selected], queryFn: () => listCacheParameters(selected), enabled: runtimeReachable && Boolean(selected)}); useEffect(() => {if (!selected && groups.data?.[0]?.CacheParameterGroupName) setSelected(groups.data[0].CacheParameterGroupName);}, [groups.data, selected]);
const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "parameter-groups"]}); const create = useMutation({mutationFn: () => createCacheParameterGroup({cacheParameterGroupName: name.trim(), cacheParameterGroupFamily: family.trim(), description: description.trim()}), onSuccess: () => {setName(""); refresh();}}); const remove = useMutation({mutationFn: deleteCacheParameterGroup, onSuccess: refresh}); const set = useMutation({mutationFn: () => setCacheParameters(selected, [{parameterName: parameterName.trim(), parameterValue: parameterValue.trim()}]), onSuccess: () => {setParameterName(""); setParameterValue(""); void qc.invalidateQueries({queryKey: ["elasticache", "parameters", selected]});}});
return <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); create.mutate();}}><label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(e) => setName(e.target.value)} required/></label><label className="dynamic-field"><span>Family</span><input className="input" value={family} onChange={(e) => setFamily(e.target.value)} required/></label><label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} required/></label><button className="button primary" type="submit" disabled={!runtimeReachable || create.isPending}><Plus size={13}/>Create parameter group</button></form><ActionError error={create.error ?? remove.error ?? set.error}/><ListHeader onRefresh={() => groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) => <div className="apigateway-route-item" key={group.CacheParameterGroupName}><div className="apigateway-route-select"><code>{group.CacheParameterGroupName}</code><small>{group.CacheParameterGroupFamily} · {group.Description}</small></div><button className="button compact" type="button" onClick={() => setSelected(group.CacheParameterGroupName ?? "")}>Parameters</button><button className="icon-btn danger" type="button" aria-label={`Delete parameter group ${group.CacheParameterGroupName}`} onClick={() => group.CacheParameterGroupName && remove.mutate(group.CacheParameterGroupName)}><Trash2 size={14}/></button></div>)}{selected && <form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); set.mutate();}}><label className="dynamic-field"><span>Parameter group</span><input className="input" value={selected} readOnly/></label><label className="dynamic-field"><span>Parameter name</span><input className="input" value={parameterName} onChange={(e) => setParameterName(e.target.value)} required/></label><label className="dynamic-field"><span>Parameter value</span><input className="input" value={parameterValue} onChange={(e) => setParameterValue(e.target.value)} required/></label><button className="button primary" type="submit" disabled={set.isPending}>Set parameter</button></form>}{parameters.data?.map((parameter) => <div className="apigateway-route-item" key={parameter.ParameterName}><div className="apigateway-route-select"><code>{parameter.ParameterName}</code><small>{parameter.ParameterValue} {parameter.IsModifiable ? "(modifiable)" : ""}</small></div></div>)}</>;
}
function ListHeader({onRefresh, loading}: {onRefresh: () => void; loading: boolean}) { return <div className="resource-table-tools" style={{margin: "12px 0"}}><button className="button compact" type="button" onClick={onRefresh} disabled={loading}><RefreshCw size={13}/>{loading ? "Loading" : "Refresh"}</button></div>; }
function ActionError({error}: {error: Error | null}) { return error ? <p className="error-text compact-text">{error.message}</p> : null; }
@@ -0,0 +1,71 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {expect, test, vi} from 'vitest'
import {RedisDataExplorer} from './RedisDataExplorer'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
connectRedis: vi.fn(),
scanRedisKeys: vi.fn(),
getRedisKeyDetails: vi.fn(),
setRedisString: vi.fn(),
deleteRedisKey: vi.fn(),
}))
vi.mock('@/api/elasticacheClient', () => apiMocks)
const cache: CloudResource = {
id: 'orders', name: 'orders', cloud: 'aws', service: 'elasticache', type: 'replication-group',
region: 'us-east-1', createdAt: null, engine: 'redis', metadata: {},
}
function renderExplorer() {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={queryClient}><RedisDataExplorer resource={cache} runtimeReachable/></QueryClientProvider>)
}
test('connects, scans matching keys, and inspects a typed value', async () => {
apiMocks.connectRedis.mockResolvedValue({ping: 'PONG', target: {engine: 'redis', endpoint: {address: 'redis.local', port: 6379, kind: 'primary'}}})
apiMocks.scanRedisKeys.mockResolvedValue({cursor: '0', keys: ['order:1']})
apiMocks.getRedisKeyDetails.mockResolvedValue({key: 'order:1', type: 'hash', ttlSeconds: 60, value: {status: 'new'}})
const user = userEvent.setup()
renderExplorer()
await user.type(screen.getByLabelText('Redis username'), 'app')
await user.type(screen.getByLabelText('Redis password'), 'secret')
await user.click(screen.getByRole('button', {name: 'Connect and ping'}))
await screen.findByText('redis.local:6379 (primary)')
expect(apiMocks.connectRedis).toHaveBeenCalledWith({resourceId: 'orders', credentials: {username: 'app', password: 'secret'}})
await user.type(screen.getByLabelText('Key match pattern'), 'order:*')
await user.click(screen.getByRole('button', {name: 'Scan keys'}))
expect(await screen.findByText('order:1')).toBeInTheDocument()
expect(apiMocks.scanRedisKeys).toHaveBeenCalledWith({resourceId: 'orders', credentials: {username: 'app', password: 'secret'}, match: 'order:*'})
await user.click(screen.getByRole('button', {name: 'order:1'}))
await screen.findByText('hash · 60s TTL')
expect(screen.getByText(/"status": "new"/)).toBeInTheDocument()
})
test('sets a string with a TTL and deletes the selected key', async () => {
apiMocks.connectRedis.mockResolvedValue({ping: 'PONG', target: {engine: 'valkey', endpoint: {address: 'cache.local', port: 6379, kind: 'configuration'}}})
apiMocks.scanRedisKeys.mockResolvedValue({cursor: '0', keys: ['order:1']})
apiMocks.getRedisKeyDetails.mockResolvedValue({key: 'order:1', type: 'string', ttlSeconds: -1, value: 'pending'})
apiMocks.setRedisString.mockResolvedValue({ok: true})
apiMocks.deleteRedisKey.mockResolvedValue({deleted: true})
const user = userEvent.setup()
renderExplorer()
await user.click(screen.getByRole('button', {name: 'Connect and ping'}))
await user.click(screen.getByRole('button', {name: 'Scan keys'}))
await user.click(await screen.findByRole('button', {name: 'order:1'}))
await user.click(await screen.findByRole('button', {name: 'Delete key'}))
await waitFor(() => expect(apiMocks.deleteRedisKey).toHaveBeenCalledWith({resourceId: 'orders', key: 'order:1'}))
await user.type(screen.getByLabelText('New Redis key'), 'order:2')
await user.type(screen.getByLabelText('New Redis value'), 'created')
await user.type(screen.getByLabelText('TTL seconds'), '30')
await user.click(screen.getByRole('button', {name: 'Set string'}))
await waitFor(() => expect(apiMocks.setRedisString).toHaveBeenCalledWith({resourceId: 'orders', key: 'order:2', value: 'created', ttlSeconds: 30}))
})
@@ -0,0 +1,150 @@
import {useEffect, useState} from 'react'
import {Database, KeyRound, Plus, Trash2} from 'lucide-react'
import {useMutation} from '@tanstack/react-query'
import {connectRedis, deleteRedisKey, getRedisKeyDetails, scanRedisKeys, setRedisString} from '@/api/elasticacheClient'
import type {CloudResource} from '@/types/resource'
interface RedisDataExplorerProps {
resource: CloudResource
runtimeReachable: boolean
}
export function RedisDataExplorer({resource, runtimeReachable}: RedisDataExplorerProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [connected, setConnected] = useState<Awaited<ReturnType<typeof connectRedis>>>()
const [match, setMatch] = useState('')
const [keys, setKeys] = useState<string[]>([])
const [cursor, setCursor] = useState<string | undefined>()
const [selectedKey, setSelectedKey] = useState<string>()
const [details, setDetails] = useState<Awaited<ReturnType<typeof getRedisKeyDetails>>>()
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [ttl, setTtl] = useState('')
const credentials = username || password ? {username: username || undefined, password: password || undefined} : undefined
const request = {resourceId: resource.id, ...(credentials && {credentials})}
const connection = useMutation({
mutationFn: () => connectRedis(request),
onSuccess: (result) => {
setConnected(result)
setKeys([])
setCursor(undefined)
setSelectedKey(undefined)
setDetails(undefined)
},
})
const scan = useMutation({
mutationFn: ({nextCursor}: {nextCursor?: string; append: boolean}) => scanRedisKeys({...request, cursor: nextCursor, match: match || undefined}),
onSuccess: (result, variables) => {
setKeys((current) => variables.append ? [...new Set([...current, ...result.keys])] : result.keys)
setCursor(result.cursor === '0' ? undefined : result.cursor)
},
})
const inspect = useMutation({
mutationFn: (key: string) => getRedisKeyDetails({...request, key}),
onSuccess: (result, key) => {
setSelectedKey(key)
setDetails(result)
},
})
const setString = useMutation({
mutationFn: () => setRedisString({...request, key: newKey, value: newValue, ...(ttl ? {ttlSeconds: Number(ttl)} : {})}),
onSuccess: () => {
setNewKey('')
setNewValue('')
setTtl('')
void scan.mutate({append: false})
},
})
const remove = useMutation({
mutationFn: (key: string) => deleteRedisKey({...request, key}),
onSuccess: (_result, key) => {
setKeys((current) => current.filter((item) => item !== key))
if (selectedKey === key) {
setSelectedKey(undefined)
setDetails(undefined)
}
},
})
useEffect(() => {
setConnected(undefined)
setKeys([])
setCursor(undefined)
setSelectedKey(undefined)
setDetails(undefined)
}, [resource.id])
function connect(event: React.FormEvent) {
event.preventDefault()
connection.mutate()
}
function scanKeys(event: React.FormEvent) {
event.preventDefault()
scan.mutate({append: false})
}
function addString(event: React.FormEvent) {
event.preventDefault()
if (!newKey || setString.isPending) return
setString.mutate()
}
const error = connection.error ?? scan.error ?? inspect.error ?? setString.error ?? remove.error
const endpoint = connected?.target.endpoint
return <section className="redis-explorer">
<div className="cosmos-column redis-connection">
<PanelHeader icon={<Database size={18}/>} eyebrow="Redis connection" title={resource.name} detail={connected ? `${connected.target.engine} connected` : 'Connect to inspect cache data'}/>
<form className="redis-form" onSubmit={connect}>
<label>Username<input aria-label="Redis username" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="Optional"/></label>
<label>Password<input aria-label="Redis password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Optional"/></label>
<button className="button primary" type="submit" disabled={!runtimeReachable || connection.isPending}>{connection.isPending ? 'Connecting' : connected ? 'Ping again' : 'Connect and ping'}</button>
</form>
{connected && <div className="redis-endpoint"><strong>{connected.ping}</strong><span>{endpoint?.address}:{endpoint?.port} ({endpoint?.kind})</span></div>}
<p className="redis-note">Credentials are used only for this session and are not stored.</p>
</div>
<div className="cosmos-column cosmos-column--wide">
<PanelHeader icon={<KeyRound size={18}/>} eyebrow="Keys" title={match ? `Match: ${match}` : 'All keys'} detail={`${keys.length} loaded`}/>
<form className="redis-scan" onSubmit={scanKeys}>
<label><span className="sr-only">Key match pattern</span><input aria-label="Key match pattern" value={match} onChange={(event) => setMatch(event.target.value)} placeholder="Match pattern, for example order:*"/></label>
<button className="button" type="submit" disabled={!connected || scan.isPending}>{scan.isPending ? 'Scanning' : 'Scan keys'}</button>
</form>
<div className="redis-key-list">
{!connected && <div className="empty compact"><h3>Connect to browse keys</h3><p>Redis commands run only after a successful ping.</p></div>}
{connected && !scan.isPending && keys.length === 0 && <div className="empty compact"><h3>No keys loaded</h3><p>Scan Redis to list keys matching the optional pattern.</p></div>}
{keys.map((key) => <button key={key} className={`redis-key ${selectedKey === key ? 'selected' : ''}`} type="button" onClick={() => inspect.mutate(key)}><KeyRound size={14}/><span>{key}</span></button>)}
{cursor && <button className="button redis-load-more" type="button" disabled={scan.isPending} onClick={() => scan.mutate({nextCursor: cursor, append: true})}>{scan.isPending ? 'Scanning' : 'Load more'}</button>}
</div>
</div>
<div className="cosmos-column cosmos-column--wide">
<PanelHeader icon={<KeyRound size={18}/>} eyebrow="Value" title={details?.key ?? 'Select a key'} detail={details ? `${details.type} · ${formatTtl(details.ttlSeconds)}` : 'Type and TTL appear here'}/>
{selectedKey && <div className="redis-key-actions"><button className="button danger compact" type="button" disabled={remove.isPending} onClick={() => remove.mutate(selectedKey)}><Trash2 size={13}/>{remove.isPending ? 'Deleting' : 'Delete key'}</button></div>}
{details ? <pre className="redis-value">{formatValue(details.value)}</pre> : <div className="empty compact"><h3>Select a key</h3><p>Its type, TTL, and value will appear here.</p></div>}
<form className="redis-form redis-set-form" onSubmit={addString}>
<strong><Plus size={14}/> Set string</strong>
<label>Key<input aria-label="New Redis key" required value={newKey} onChange={(event) => setNewKey(event.target.value)}/></label>
<label>Value<textarea aria-label="New Redis value" required value={newValue} onChange={(event) => setNewValue(event.target.value)}/></label>
<label>TTL seconds<input aria-label="TTL seconds" type="number" min="1" value={ttl} onChange={(event) => setTtl(event.target.value)} placeholder="Optional"/></label>
<button className="button primary" type="submit" disabled={!connected || setString.isPending}>{setString.isPending ? 'Saving' : 'Set string'}</button>
</form>
{error instanceof Error && <div className="form-error">{error.message}</div>}
</div>
</section>
}
function PanelHeader({icon, eyebrow, title, detail}: {icon: React.ReactNode; eyebrow: string; title: string; detail: string}) {
return <div className="cosmos-panel-header">{icon}<span><small>{eyebrow}</small><strong>{title}</strong><em>{detail}</em></span></div>
}
function formatTtl(ttlSeconds: number): string {
if (ttlSeconds === -1) return 'no expiry'
if (ttlSeconds === -2) return 'expired'
return `${ttlSeconds}s TTL`
}
function formatValue(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
+34
View File
@@ -5570,8 +5570,13 @@ button.console-service-card {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
} }
.data-workspace > .redis-explorer {
grid-template-columns: minmax(0, .8fr) minmax(0, 1fr) minmax(0, 1.1fr);
}
.data-workspace > .cosmos-panel, .data-workspace > .cosmos-panel,
.data-workspace > .dynamodb-explorer, .data-workspace > .dynamodb-explorer,
.data-workspace > .redis-explorer,
.data-workspace > .sql-panel, .data-workspace > .sql-panel,
.data-workspace .sql-explorer { .data-workspace .sql-explorer {
min-height: 0; min-height: 0;
@@ -5633,6 +5638,7 @@ button.console-service-card {
@container (max-width: 1000px) { @container (max-width: 1000px) {
.data-workspace > .cosmos-panel, .data-workspace > .cosmos-panel,
.data-workspace > .redis-explorer,
.data-workspace .sql-explorer { .data-workspace .sql-explorer {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start; align-content: start;
@@ -5660,6 +5666,7 @@ button.console-service-card {
@container (max-width: 600px) { @container (max-width: 600px) {
.data-workspace > .cosmos-panel, .data-workspace > .cosmos-panel,
.data-workspace > .dynamodb-explorer, .data-workspace > .dynamodb-explorer,
.data-workspace > .redis-explorer,
.data-workspace .sql-explorer, .data-workspace .sql-explorer,
.data-workspace .sql-connection-bar { .data-workspace .sql-connection-bar {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -5670,3 +5677,30 @@ button.console-service-card {
grid-template-rows: auto auto; grid-template-rows: auto auto;
} }
} }
.redis-explorer {
display: grid;
grid-template-columns: minmax(240px, .8fr) minmax(300px, 1fr) minmax(340px, 1.1fr);
gap: 10px;
min-height: 360px;
}
.redis-form { display: grid; gap: 10px; padding: 12px; }
.redis-form label { display: grid; gap: 4px; color: var(--text-2); font-size: 11px; font-weight: 600; }
.redis-form input, .redis-form textarea, .redis-scan input { width: 100%; box-sizing: border-box; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text); font: inherit; font-size: 12px; padding: 6px 8px; }
.redis-form textarea { min-height: 68px; resize: vertical; }
.redis-endpoint { display: grid; gap: 3px; padding: 10px 12px; border-top: 1px solid var(--border); color: var(--text-2); font-size: 11px; overflow-wrap: anywhere; }
.redis-endpoint strong { color: var(--status-success); font-size: 12px; }
.redis-note { margin: 0; padding: 0 12px 12px; color: var(--text-3); font-size: 11px; line-height: 1.45; }
.redis-scan { display: flex; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--raised); }
.redis-scan label { flex: 1; min-width: 0; }
.redis-key-list { flex: 1; min-height: 120px; overflow: auto; padding: 5px; }
.redis-key { display: flex; width: 100%; align-items: center; gap: 7px; padding: 8px; border: 0; border-radius: 3px; background: transparent; color: var(--text); font: inherit; font-size: 12px; text-align: left; cursor: pointer; }
.redis-key:hover, .redis-key.selected { background: var(--raised); }
.redis-key.selected { color: var(--link); }
.redis-key span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.redis-load-more { display: block; margin: 10px auto; }
.redis-key-actions { display: flex; justify-content: flex-end; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.redis-value { flex: 1; min-height: 110px; max-height: 310px; overflow: auto; margin: 0; padding: 12px; color: var(--text); font-size: 11px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.redis-set-form { border-top: 1px solid var(--border); background: var(--raised); }
.redis-set-form strong { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 12px; }
@@ -25,4 +25,11 @@ describe('dataExplorerPath', () => {
test('does not expose unsupported RDS engines as SQL explorers', () => { test('does not expose unsupported RDS engines as SQL explorers', () => {
expect(dataExplorerKind(rdsInstance('mariadb'))).toBeUndefined() expect(dataExplorerKind(rdsInstance('mariadb'))).toBeUndefined()
}) })
test('enables the Redis explorer only for Redis and Valkey ElastiCache resources', () => {
const cache = (engine: string): CloudResource => ({...rdsInstance(engine), service: 'elasticache', type: 'replication-group'})
expect(dataExplorerKind(cache('redis'))).toBe('redis')
expect(dataExplorerPath({...cache('valkey'), type: 'cache-cluster'})).toBe('/cloud-explorer/aws/elasticache/orders-db/data')
expect(dataExplorerKind(cache('memcached'))).toBeUndefined()
})
}) })
+4 -1
View File
@@ -1,12 +1,15 @@
import type {CloudResource} from '@/types/resource' import type {CloudResource} from '@/types/resource'
export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos' | 'sql' | undefined { export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos' | 'sql' | 'redis' | undefined {
if (resource.cloud === 'aws' && resource.service === 'nosql' && resource.type === 'dynamodb-table') return 'dynamodb' if (resource.cloud === 'aws' && resource.service === 'nosql' && resource.type === 'dynamodb-table') return 'dynamodb'
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos' if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'database' && if (resource.cloud === 'azure' && resource.service === 'database' &&
(resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql' (resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql'
if (resource.cloud === 'aws' && resource.service === 'database' && resource.type === 'db-instance' && if (resource.cloud === 'aws' && resource.service === 'database' && resource.type === 'db-instance' &&
(resource.engine === 'postgres' || resource.engine === 'mysql')) return 'sql' (resource.engine === 'postgres' || resource.engine === 'mysql')) return 'sql'
if (resource.cloud === 'aws' && resource.service === 'elasticache' &&
(resource.type === 'replication-group' || resource.type === 'cache-cluster') &&
(resource.engine === 'redis' || resource.engine === 'valkey')) return 'redis'
} }
export function dataExplorerPath(resource: CloudResource): string | undefined { export function dataExplorerPath(resource: CloudResource): string | undefined {
@@ -7,6 +7,7 @@ import {useCloudServicesQuery, useCloudStatusQuery} from '@/api/queries/cloudQue
import {AzureSqlPanel} from '@/components/AzureSqlPanel' import {AzureSqlPanel} from '@/components/AzureSqlPanel'
import {CosmosNoSqlPanel} from '@/components/CosmosNoSqlPanel' import {CosmosNoSqlPanel} from '@/components/CosmosNoSqlPanel'
import {DynamoDbTableExplorer} from '@/components/DynamoDbTableExplorer' import {DynamoDbTableExplorer} from '@/components/DynamoDbTableExplorer'
import {RedisDataExplorer} from '@/components/RedisDataExplorer'
import {useAccountId} from '@/lib/accountStore' import {useAccountId} from '@/lib/accountStore'
import {dataExplorerKind} from '@/lib/dataExplorer' import {dataExplorerKind} from '@/lib/dataExplorer'
import type {CloudProvider} from '@/types/cloud' import type {CloudProvider} from '@/types/cloud'
@@ -62,6 +63,10 @@ function DatabaseDataWorkspace({cloud, service, resourceId}: {cloud: CloudProvid
if (kind === 'dynamodb') return <DynamoDbTableExplorer cloud={cloud} resource={resource} runtimeReachable/> if (kind === 'dynamodb') return <DynamoDbTableExplorer cloud={cloud} resource={resource} runtimeReachable/>
if (kind === 'cosmos') return <CosmosNoSqlPanel key={containerId ?? ''} cloud={cloud} resource={resource} runtimeReachable selectedContainerId={containerId} onSelectContainer={selectContainer}/> if (kind === 'cosmos') return <CosmosNoSqlPanel key={containerId ?? ''} cloud={cloud} resource={resource} runtimeReachable selectedContainerId={containerId} onSelectContainer={selectContainer}/>
if (kind === 'sql') return <AzureSqlPanel cloud={cloud} resource={resource} runtimeReachable/> if (kind === 'sql') return <AzureSqlPanel cloud={cloud} resource={resource} runtimeReachable/>
if (kind === 'redis') return <RedisDataExplorer resource={resource} runtimeReachable/>
if (resource.cloud === 'aws' && resource.service === 'elasticache' && resource.engine === 'memcached') {
return <DataNotice title="Data exploration unavailable" detail="Memcached supports management operations only. Redis and Valkey caches can be explored here."/>
}
return <DataNotice title="Data exploration unavailable" detail="This resource supports management operations only."/> return <DataNotice title="Data exploration unavailable" detail="This resource supports management operations only."/>
} }
+90
View File
@@ -52,6 +52,9 @@ importers:
'@aws-sdk/client-elastic-load-balancing-v2': '@aws-sdk/client-elastic-load-balancing-v2':
specifier: ^3.1096.0 specifier: ^3.1096.0
version: 3.1122.0 version: 3.1122.0
'@aws-sdk/client-elasticache':
specifier: ^3.1146.0
version: 3.1146.0
'@aws-sdk/client-eventbridge': '@aws-sdk/client-eventbridge':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1121.0 version: 3.1121.0
@@ -118,6 +121,9 @@ importers:
pg: pg:
specifier: ^8.22.0 specifier: ^8.22.0
version: 8.23.0 version: 8.23.0
redis:
specifier: ^6.3.0
version: 6.3.0
devDependencies: devDependencies:
'@types/bun': '@types/bun':
specifier: ^1.3.2 specifier: ^1.3.2
@@ -330,6 +336,10 @@ packages:
resolution: {integrity: sha512-7uMkQNqmVer3ST7cAOenfswJFxWM/KLe+bFFPQDfLO9HS8v7fDBdPKHVUMSFrBRsyN3A87K9nDQoYMChaBwHWQ==} resolution: {integrity: sha512-7uMkQNqmVer3ST7cAOenfswJFxWM/KLe+bFFPQDfLO9HS8v7fDBdPKHVUMSFrBRsyN3A87K9nDQoYMChaBwHWQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-elasticache@3.1146.0':
resolution: {integrity: sha512-5GK4FAu5dzVW46PNg/tvGcI83HJJqcVdLBtYpVKZ0AlN21DL1qG9d4dR5Kj879uXSgMJbghcVrzX2BygbW3PzA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-eventbridge@3.1121.0': '@aws-sdk/client-eventbridge@3.1121.0':
resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==} resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -1052,6 +1062,42 @@ packages:
engines: {node: '>=20'} engines: {node: '>=20'}
hasBin: true hasBin: true
'@redis/bloom@6.3.0':
resolution: {integrity: sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==}
engines: {node: '>= 20.0.0'}
peerDependencies:
'@redis/client': ^6.3.0
'@redis/client@6.3.0':
resolution: {integrity: sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==}
engines: {node: '>= 20.0.0'}
peerDependencies:
'@node-rs/xxhash': ^1.1.0
'@opentelemetry/api': '>=1 <2'
peerDependenciesMeta:
'@node-rs/xxhash':
optional: true
'@opentelemetry/api':
optional: true
'@redis/json@6.3.0':
resolution: {integrity: sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==}
engines: {node: '>= 20.0.0'}
peerDependencies:
'@redis/client': ^6.3.0
'@redis/search@6.3.0':
resolution: {integrity: sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==}
engines: {node: '>= 20.0.0'}
peerDependencies:
'@redis/client': ^6.3.0
'@redis/time-series@6.3.0':
resolution: {integrity: sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==}
engines: {node: '>= 20.0.0'}
peerDependencies:
'@redis/client': ^6.3.0
'@rolldown/binding-android-arm64@1.1.4': '@rolldown/binding-android-arm64@1.1.4':
resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -2326,6 +2372,10 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'} engines: {node: '>=8'}
redis@6.3.0:
resolution: {integrity: sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==}
engines: {node: '>= 20.0.0'}
require-from-string@2.0.2: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2906,6 +2956,17 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-elasticache@3.1146.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-eventbridge@3.1121.0': '@aws-sdk/client-eventbridge@3.1121.0':
dependencies: dependencies:
'@aws-sdk/core': 3.977.9 '@aws-sdk/core': 3.977.9
@@ -4317,6 +4378,24 @@ snapshots:
dependencies: dependencies:
playwright: 1.63.0 playwright: 1.63.0
'@redis/bloom@6.3.0(@redis/client@6.3.0)':
dependencies:
'@redis/client': 6.3.0
'@redis/client@6.3.0': {}
'@redis/json@6.3.0(@redis/client@6.3.0)':
dependencies:
'@redis/client': 6.3.0
'@redis/search@6.3.0(@redis/client@6.3.0)':
dependencies:
'@redis/client': 6.3.0
'@redis/time-series@6.3.0(@redis/client@6.3.0)':
dependencies:
'@redis/client': 6.3.0
'@rolldown/binding-android-arm64@1.1.4': '@rolldown/binding-android-arm64@1.1.4':
optional: true optional: true
@@ -5584,6 +5663,17 @@ snapshots:
indent-string: 4.0.0 indent-string: 4.0.0
strip-indent: 3.0.0 strip-indent: 3.0.0
redis@6.3.0:
dependencies:
'@redis/bloom': 6.3.0(@redis/client@6.3.0)
'@redis/client': 6.3.0
'@redis/json': 6.3.0(@redis/client@6.3.0)
'@redis/search': 6.3.0(@redis/client@6.3.0)
'@redis/time-series': 6.3.0(@redis/client@6.3.0)
transitivePeerDependencies:
- '@node-rs/xxhash'
- '@opentelemetry/api'
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
rolldown@1.1.4: rolldown@1.1.4: