feat: ElastiCache
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -1041,6 +1042,7 @@ export function DynamicResourceView({
|
|||||||
<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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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."/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+90
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user