Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,5 @@ temp/

data
local
.atl
openspec
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "pnpm --filter @floci/frontend build",
"lint": "pnpm --filter @floci/frontend lint",
"type-check": "pnpm --filter @floci/api type-check && pnpm --filter @floci/frontend type-check",
"test": "pnpm --filter @floci/api test",
"test": "pnpm --filter @floci/api test && pnpm --filter @floci/frontend test",
"dev:api": "sh -c 'PORT=${PORT:-4501} pnpm --filter @floci/api dev'",
"start:api": "sh -c 'PORT=${PORT:-4501} pnpm --filter @floci/api start'"
}
Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "bun test"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1066.0",
"@aws-sdk/client-ec2": "^3.1063.0",
"@aws-sdk/client-eks": "^3.1063.0",
"@aws-sdk/client-lambda": "^3.1063.0",
Expand Down
108 changes: 108 additions & 0 deletions packages/api/src/adapter-aws/AwsDynamoDbAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {describe, expect, test} from 'bun:test'
import {AwsDynamoDbAdapter} from './AwsDynamoDbAdapter'
import type {DynamoDbTable} from '../services/dynamodb'

const baseTable: DynamoDbTable = {
tableName: 'users',
arn: 'arn:aws:dynamodb:us-east-1:123456789012:table/users',
status: 'ACTIVE',
billingMode: 'PAY_PER_REQUEST',
itemCount: 12,
sizeBytes: 256,
keySchema: [{attributeName: 'pk', keyType: 'HASH'}],
region: 'us-east-1',
createdAt: '2025-01-01T00:00:00.000Z',
}

function fakeService(overrides: Partial<{
listTables: () => Promise<DynamoDbTable[]>
describeTable: (tableName: string) => Promise<DynamoDbTable>
}> = {}) {
return {
listTables: async () => [baseTable],
describeTable: async (_tableName: string) => baseTable,
...overrides,
}
}

describe('AwsDynamoDbAdapter', () => {
test('list returns mapped CloudResource array', async () => {
const adapter = new AwsDynamoDbAdapter(fakeService())
const result = await adapter.list()

expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
id: 'users',
name: 'users',
cloud: 'aws',
service: 'dynamodb',
type: 'dynamodb-table',
status: 'ACTIVE',
region: 'us-east-1',
})
expect(result[0].metadata).toEqual({
arn: 'arn:aws:dynamodb:us-east-1:123456789012:table/users',
billingMode: 'PAY_PER_REQUEST',
itemCount: 12,
sizeBytes: 256,
keySchema: [{attributeName: 'pk', keyType: 'HASH'}],
})
})

test('list filters results by search term', async () => {
const adapter = new AwsDynamoDbAdapter(fakeService({
listTables: async () => [
baseTable,
{...baseTable, tableName: 'orders'},
],
}))

const result = await adapter.list({search: 'user'})

expect(result).toHaveLength(1)
expect(result[0].name).toBe('users')
})

test('list returns an empty array when no tables exist', async () => {
const adapter = new AwsDynamoDbAdapter(fakeService({
listTables: async () => [],
}))

await expect(adapter.list()).resolves.toEqual([])
})

test('get returns null when the table is not found', async () => {
const notFound = Object.assign(new Error('Requested resource not found'), {
name: 'ResourceNotFoundException',
$metadata: {httpStatusCode: 404},
})
const adapter = new AwsDynamoDbAdapter(fakeService({
describeTable: async () => {
throw notFound
},
}))

await expect(adapter.get('missing')).resolves.toBeNull()
})

test('get rethrows non-404 errors', async () => {
const adapter = new AwsDynamoDbAdapter(fakeService({
describeTable: async () => {
throw new Error('boom')
},
}))

await expect(adapter.get('users')).rejects.toThrow('boom')
})

test('schema returns the aws dynamodb schema', () => {
const adapter = new AwsDynamoDbAdapter(fakeService())
const schema = adapter.schema()

expect(schema.cloud).toBe('aws')
expect(schema.service).toBe('dynamodb')
expect(schema.displayName).toBe('DynamoDB')
expect(schema.actions).toContain('list')
expect(schema.actions).toContain('inspect')
})
})
80 changes: 80 additions & 0 deletions packages/api/src/adapter-aws/AwsDynamoDbAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
import {dynamoDbService, type DynamoDbTable} from '../services/dynamodb'

type DynamoDbServiceShape = {
listTables(): Promise<DynamoDbTable[]>
describeTable(tableName: string): Promise<DynamoDbTable>
}

export class AwsDynamoDbAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'dynamodb' as const

constructor(private readonly service_: DynamoDbServiceShape = dynamoDbService) {}

schema(): ServiceSchema {
return awsDynamoDbSchema()
}

async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources = (await this.service_.listTables()).map(tableToResource)
return filterBySearch(resources, query.search)
}

async get(id: string): Promise<CloudResource | null> {
try {
return tableToResource(await this.service_.describeTable(id))
} catch (error) {
if (hasNotFoundStatus(error)) return null
throw error
}
}

async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new Error('DynamoDB table creation is not supported from the dynamic Cloud Explorer.')
}

async delete(_id: string): Promise<void> {
throw new Error('DynamoDB table deletion is not supported from the dynamic Cloud Explorer.')
}
}

function tableToResource(table: DynamoDbTable): CloudResource {
return {
id: table.tableName,
name: table.tableName,
cloud: 'aws',
service: 'dynamodb',
type: 'dynamodb-table',
region: table.region,
createdAt: table.createdAt ?? null,
status: table.status ?? null,
metadata: {
arn: table.arn,
billingMode: table.billingMode,
itemCount: table.itemCount,
sizeBytes: table.sizeBytes,
keySchema: table.keySchema,
},
}
}

function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}

function hasNotFoundStatus(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata
const name = 'name' in error ? error.name : undefined
return metadata?.httpStatusCode === 404 || name === 'ResourceNotFoundException'
}
12 changes: 12 additions & 0 deletions packages/api/src/aws.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {describe, expect, test} from 'bun:test'
import {awsClients, awsRegion} from './aws'

describe('aws client registry', () => {
test('exposes a dynamodb client', () => {
expect(typeof awsClients.dynamodb.send).toBe('function')
})

test('exports the resolved aws region', () => {
expect(awsRegion).toBe(process.env.AWS_REGION ?? 'us-east-1')
})
})
8 changes: 6 additions & 2 deletions packages/api/src/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { EKSClient } from "@aws-sdk/client-eks";
import { EC2Client } from "@aws-sdk/client-ec2";
import { RDSClient } from "@aws-sdk/client-rds";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
export const awsRegion = process.env.AWS_REGION || "us-east-1";

// Floci derives the AWS account from AWS_ACCESS_KEY_ID: a value that is exactly
// 12 digits is used verbatim as the account id, and resources are isolated per
Expand Down Expand Up @@ -39,6 +40,7 @@ export type AwsClients = {
ec2: EC2Client;
rds: RDSClient;
secretsManager: SecretsManagerClient;
dynamodb: DynamoDBClient;
};

export type AwsClientName = keyof AwsClients;
Expand All @@ -47,7 +49,7 @@ const clientCache = new Map<string, AwsClients>();

function buildClients(accountId: string): AwsClients {
const base = {
region,
region: awsRegion,
credentials: { accessKeyId: accountId, secretAccessKey: SECRET_ACCESS_KEY },
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
};
Expand All @@ -58,6 +60,7 @@ function buildClients(accountId: string): AwsClients {
ec2: new EC2Client(base),
rds: new RDSClient(base),
secretsManager: new SecretsManagerClient(base),
dynamodb: new DynamoDBClient(base),
};
}

Expand Down Expand Up @@ -89,3 +92,4 @@ export const eks = awsClients.eks;
export const ec2 = awsClients.ec2;
export const rds = awsClients.rds;
export const secretsManager = awsClients.secretsManager;
export const dynamodb = awsClients.dynamodb;
26 changes: 26 additions & 0 deletions packages/api/src/cloud-spi/dynamodbSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {describe, expect, test} from 'bun:test'
import {awsDynamoDbSchema, dynamodbSchemaFor} from './dynamodbSchema'

describe('dynamodb schema', () => {
test('returns the aws dynamodb schema', () => {
const schema = awsDynamoDbSchema()

expect(schema.cloud).toBe('aws')
expect(schema.service).toBe('dynamodb')
expect(schema.displayName).toBe('DynamoDB')
expect(schema.actions).toEqual(['list', 'inspect'])
expect(schema.columns.map((column) => column.name)).toEqual([
'name',
'status',
'billingMode',
'itemCount',
'sizeBytes',
])
})

test('returns a provider fallback only for aws', () => {
expect(dynamodbSchemaFor('aws')?.displayName).toBe('DynamoDB')
expect(dynamodbSchemaFor('azure')).toBeNull()
expect(dynamodbSchemaFor('gcp')).toBeNull()
})
})
30 changes: 30 additions & 0 deletions packages/api/src/cloud-spi/dynamodbSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'

const dynamodbColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'Status'},
{name: 'billingMode', label: 'Billing Mode'},
{name: 'itemCount', label: 'Item Count'},
{name: 'sizeBytes', label: 'Size (Bytes)'},
Comment on lines +3 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DynamoDB columns will always render as dashes in the resource table

The three service-specific columns — billingMode, itemCount, and sizeBytes — point to field names that don't exist at the top level of CloudResource. ResourceTable resolves columns via resource[column.name as keyof CloudResource], so any column name that isn't a known top-level property returns undefined, which formatValue renders as '-'. Compare: the EKS schema uses a version column because version is a first-class field on CloudResource, and the Database schema uses engine / instanceClass for the same reason.

The fix is either to promote billingMode, itemCount, and sizeBytes to top-level fields on CloudResource (both backend types.ts and frontend types/resource.ts) and expose them directly from tableToResource, or alternatively to teach ResourceTable to fall back to resource.metadata[column.name] when a column name isn't a recognized top-level key.

]

const dynamodbFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]

export function awsDynamoDbSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'dynamodb',
displayName: 'DynamoDB',
fields: [],
actions: ['list', 'inspect'],
filters: dynamodbFilters,
columns: dynamodbColumns,
}
}

export function dynamodbSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'aws') return awsDynamoDbSchema()
return null
}
4 changes: 2 additions & 2 deletions packages/api/src/cloud-spi/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type CloudProvider = 'aws' | 'azure' | 'gcp'

export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking'
export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'dynamodb' | 'serverless' | 'compute' | 'networking'

export type CloudAvailability = 'available' | 'coming_soon'

Expand Down Expand Up @@ -83,7 +83,7 @@ export interface CloudResource {
name: string
cloud: CloudProvider
service: CloudServiceType
type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function'
type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'dynamodb-table' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function'
region: string | null
createdAt: string | null
status?: string | null
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/cloudProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {CloudAdapterRegistry} from './registry/CloudAdapterRegistry'
import {AwsComputeAdapter} from './adapter-aws/AwsComputeAdapter'
import {AwsNetworkingAdapter} from './adapter-aws/AwsNetworkingAdapter'
import {AwsDatabaseAdapter} from './adapter-aws/AwsDatabaseAdapter'
import {AwsDynamoDbAdapter} from './adapter-aws/AwsDynamoDbAdapter'
import {AwsEksAdapter} from './adapter-aws/AwsEksAdapter'
import {AwsStorageAdapter} from './adapter-aws/AwsStorageAdapter'
import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter'
Expand All @@ -12,6 +13,7 @@ import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter'
import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createDynamoDbService} from './services/dynamodb'
import {createEksService} from './services/eks'
import {createRdsService} from './services/rds'

Expand All @@ -24,11 +26,13 @@ import {createRdsService} from './services/rds'
export function createCloudProxyService(accountId?: string | null): CloudProxyService {
const clients = awsClientsForAccount(accountId)
const ec2Service = createEc2Service(clients.ec2)
const dynamoDbService = createDynamoDbService(clients.dynamodb)

const registry = new CloudAdapterRegistry([
new AwsStorageAdapter(clients.s3),
new AwsEksAdapter(createEksService(clients.eks)),
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds),
new AwsDynamoDbAdapter(dynamoDbService),
new AwsComputeAdapter(ec2Service),
new AwsNetworkingAdapter(ec2Service),
new AwsServerlessAdapter(clients.lambda),
Expand Down
Loading
Loading