feat(api): add the Glue Data Catalog to the cloud explorer
CI / checks (pull_request) Successful in 28m15s

Create and delete Glue databases, and describe a folder of CSV, JSON-lines or
Parquet files in S3 as a table: its columns, and for CSV the delimiter and
whether the first line is a header. Tables are written with the storage
settings Athena expects, so TheCloud's Athena can query one as soon as it
exists. Glue jobs and crawlers are not offered: the runtime stores them but
never runs them.
This commit is contained in:
Zakaria
2026-10-25 17:51:16 -04:00
parent 26b635db0b
commit 69d80c60a3
23 changed files with 918 additions and 1 deletions
+1
View File
@@ -90,6 +90,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
| Analytics | Glue Data Catalog / Data Catalog | Yes (list, create, delete, inspect) | No | No |
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
+1
View File
@@ -32,6 +32,7 @@
"@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-glue": "^3.1146.0",
"@aws-sdk/client-iam": "^3.1076.0",
"@aws-sdk/client-kafka": "^3.1146.0",
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
@@ -0,0 +1,155 @@
import {describe, expect, test} from 'bun:test'
import {
CreateDatabaseCommand,
CreateTableCommand,
DeleteDatabaseCommand,
DeleteTableCommand,
GetDatabaseCommand,
GetDatabasesCommand,
GetTablesCommand,
type GlueClient,
} from '@aws-sdk/client-glue'
import {AwsDataCatalogAdapter, describeTable} from './AwsDataCatalogAdapter'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
const database = {Name: 'shop', Description: 'Orders and customers', LocationUri: 's3://data/shop/', CatalogId: '000000000000', CreateTime: new Date('2026-10-25T18:00:00Z')}
const ordersTable = {
Name: 'orders',
TableType: 'EXTERNAL_TABLE',
Parameters: {classification: 'csv', 'skip.header.line.count': '1'},
StorageDescriptor: {
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
Location: 's3://data/orders/',
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
},
PartitionKeys: [{Name: 'day', Type: 'string'}],
}
const eventsTable = {
Name: 'events',
StorageDescriptor: {Columns: [{Name: 'kind', Type: 'string'}], Location: 's3://data/events/', SerdeInfo: {SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'}},
}
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
const result = handler(command)
if (result) return result
if (command instanceof GetDatabasesCommand) return {DatabaseList: [database]}
if (command instanceof GetDatabaseCommand) return {Database: database}
if (command instanceof GetTablesCommand) return {TableList: [ordersTable, eventsTable]}
return {}
},
}
return {sent, adapter: new AwsDataCatalogAdapter(client as unknown as GlueClient)}
}
describe('AwsDataCatalogAdapter', () => {
test('lists databases across pages with their table counts', async () => {
const {adapter} = adapterWith((command) => {
if (command instanceof GetDatabasesCommand) {
return command.input.NextToken ? {DatabaseList: [{Name: 'logs'}]} : {DatabaseList: [database], NextToken: 'n'}
}
if (command instanceof GetTablesCommand && command.input.DatabaseName === 'logs') return {TableList: []}
return undefined
})
const listed = await adapter.list()
expect(listed.map((db) => [db.name, db.metadata.tableCount, db.metadata.description])).toEqual([['shop', 2, 'Orders and customers'], ['logs', 0, null]])
expect((await adapter.list({search: 'customers'})).map((db) => db.name)).toEqual(['shop'])
})
test('describes each table with its format, location and columns, sorted by name', async () => {
const {adapter} = adapterWith()
const detail = await adapter.get('shop')
const tables = detail?.metadata.tables as Array<Record<string, unknown>>
expect(tables.map((table) => table.name)).toEqual(['events', 'orders'])
expect(tables[1]).toMatchObject({format: 'csv', delimiter: ';', headerRow: true, location: 's3://data/orders/', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [{name: 'day', type: 'string'}]})
expect(tables[0]).toMatchObject({format: 'json', delimiter: null, headerRow: false})
})
test('reads the format the way the query engine does', () => {
expect(describeTable({Name: 'p', StorageDescriptor: {InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'}}).format).toBe('parquet')
expect(describeTable({Name: 'i', Parameters: {table_type: 'ICEBERG'}}).format).toBe('iceberg')
expect(describeTable({Name: 'c'}).format).toBe('csv')
})
test('returns null for a database that does not exist', async () => {
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
expect(await adapter.get('nope')).toBeNull()
})
test('creates a database with an optional description and location', async () => {
const {adapter, sent} = adapterWith()
await adapter.create({values: {name: 'shop', description: 'Orders', locationUri: 's3://data/shop/'}})
expect((sent.find((command) => command instanceof CreateDatabaseCommand) as CreateDatabaseCommand).input).toEqual({DatabaseInput: {Name: 'shop', Description: 'Orders', LocationUri: 's3://data/shop/'}})
await expect(adapter.create({values: {name: 'Shop'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {name: 'shop', locationUri: 'https://example.com'}})).rejects.toBeInstanceOf(ValidationError)
})
test('creates a CSV table Athena can read, with its delimiter and header row', async () => {
const {adapter, sent} = adapterWith()
await adapter.createCatalogTable('shop', {name: 'orders', location: 's3://data/orders', format: 'csv', delimiter: ';', headerRow: true, columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'DOUBLE'}]})
const create = sent.find((command) => command instanceof CreateTableCommand) as CreateTableCommand
expect(create.input).toEqual({
DatabaseName: 'shop',
TableInput: {
Name: 'orders',
TableType: 'EXTERNAL_TABLE',
Parameters: {classification: 'csv', EXTERNAL: 'TRUE', 'skip.header.line.count': '1'},
StorageDescriptor: {
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
Location: 's3://data/orders/',
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
},
},
})
})
test('creates JSON and Parquet tables with their own SerDe', async () => {
const {adapter, sent} = adapterWith()
await adapter.createCatalogTable('shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
await adapter.createCatalogTable('shop', {name: 'facts', location: 's3://data/facts/', format: 'parquet', columns: [{name: 'total', type: 'decimal(12,2)'}]})
const [json, parquet] = sent.filter((command) => command instanceof CreateTableCommand) as CreateTableCommand[]
expect(json.input.TableInput?.StorageDescriptor?.SerdeInfo).toEqual({SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'})
expect(parquet.input.TableInput?.StorageDescriptor?.InputFormat).toBe('org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat')
expect(parquet.input.TableInput?.StorageDescriptor?.Columns).toEqual([{Name: 'total', Type: 'decimal(12,2)'}])
})
test('refuses tables Athena could not query', async () => {
const {adapter} = adapterWith()
const table = {name: 'orders', location: 's3://data/orders/', format: 'csv' as const, columns: [{name: 'id', type: 'int'}]}
await expect(adapter.createCatalogTable('shop', {...table, columns: []})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'int'}, {name: 'id', type: 'string'}]})).rejects.toThrow(/listed twice/)
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'uuid'}]})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, location: '/tmp/orders'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, delimiter: '||'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createCatalogTable('shop', {...table, format: 'avro' as never})).rejects.toBeInstanceOf(ValidationError)
})
test('creating a table in a missing database is a not-found', async () => {
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
await expect(adapter.createCatalogTable('nope', {name: 'orders', location: 's3://data/orders/', format: 'json', columns: [{name: 'id', type: 'int'}]})).rejects.toBeInstanceOf(NotFoundError)
})
test('deletes tables and databases', async () => {
const {adapter, sent} = adapterWith()
await adapter.deleteCatalogTable('shop', 'orders')
await adapter.delete('shop')
expect((sent.find((command) => command instanceof DeleteTableCommand) as DeleteTableCommand).input).toEqual({DatabaseName: 'shop', Name: 'orders'})
expect((sent.find((command) => command instanceof DeleteDatabaseCommand) as DeleteDatabaseCommand).input).toEqual({Name: 'shop'})
})
})
@@ -0,0 +1,257 @@
import {
CreateDatabaseCommand,
CreateTableCommand,
type Database,
DeleteDatabaseCommand,
DeleteTableCommand,
GetDatabaseCommand,
GetDatabasesCommand,
GetTablesCommand,
type GlueClient,
type StorageDescriptor,
type Table,
} from '@aws-sdk/client-glue'
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsDataCatalogSchema, CATALOG_COLUMN_TYPES, CATALOG_NAME_PATTERN, CATALOG_TABLE_FORMATS} from '../cloud-spi/dataCatalogSchema'
import type {CatalogTableInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
const DECIMAL_TYPE = /^decimal\(\d{1,2},\d{1,2}\)$/
/** The Hive storage settings Athena expects for each format, as the Glue console writes them. */
const STORAGE_FOR_FORMAT = {
csv: {
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe',
},
json: {
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe',
},
parquet: {
InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat',
OutputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat',
SerializationLibrary: 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe',
},
} as const
/**
* Talks to the AWS Glue Data Catalog, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - The catalog is what TheCloud's Athena reads: a table over CSV, JSON lines
* or Parquet files in S3 can be queried as soon as it exists.
* - Glue jobs and crawlers are stored but never run, so the console offers
* only the catalog: databases and the tables in them.
* - DeleteDatabase removes the database's tables too, as it does in AWS.
*/
export class AwsDataCatalogAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'datacatalog' as const
constructor(private readonly glue: GlueClient) {}
schema(): ServiceSchema {
return awsDataCatalogSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: Database[] = []
let next: string | undefined
do {
const res = await this.glue.send(new GetDatabasesCommand({MaxResults: 100, ...(next ? {NextToken: next} : {})}))
found.push(...(res.DatabaseList ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
const matching = found.filter((database) => !search
|| (database.Name ?? '').toLowerCase().includes(search)
|| (database.Description ?? '').toLowerCase().includes(search))
return Promise.all(matching.map(async (database) => toResource(database, {tableCount: (await this.tables(database.Name ?? '')).length})))
}
async get(id: string): Promise<CloudResource | null> {
const database = await this.database(id)
if (!database) return null
const tables = await this.tables(id)
return toResource(database, {tableCount: tables.length, tables})
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = catalogName(values.name, 'name')
const description = text(values.description)
const locationUri = text(values.locationUri)
if (locationUri && !S3_URI.test(locationUri)) throw new ValidationError('locationUri must be an S3 URI such as s3://my-bucket/warehouse/')
await this.glue.send(new CreateDatabaseCommand({
DatabaseInput: {
Name: name,
...(description ? {Description: description} : {}),
...(locationUri ? {LocationUri: locationUri} : {}),
},
}))
const created = await this.get(name)
if (!created) throw new RuntimeError(`Glue did not return the new database ${name}`)
return created
}
async delete(id: string): Promise<void> {
await this.glue.send(new DeleteDatabaseCommand({Name: id}))
}
async createCatalogTable(databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
const name = catalogName(input.name, 'name')
const location = text(input.location)
if (!S3_URI.test(location)) throw new ValidationError('location must be an S3 URI such as s3://my-bucket/orders/')
const format = text(input.format) as CatalogTableInput['format']
if (!(CATALOG_TABLE_FORMATS as readonly string[]).includes(format)) throw new ValidationError(`format must be one of ${CATALOG_TABLE_FORMATS.join(', ')}`)
const columns = tableColumns(input.columns)
const delimiter = typeof input.delimiter === 'string' && input.delimiter !== '' ? input.delimiter : ','
if (format === 'csv' && [...delimiter].length !== 1) throw new ValidationError('delimiter must be a single character')
const description = text(input.description)
if (!await this.database(databaseName)) throw new NotFoundError(`Database ${databaseName} not found`)
const storage = STORAGE_FOR_FORMAT[format]
const csv = format === 'csv'
await this.glue.send(new CreateTableCommand({
DatabaseName: databaseName,
TableInput: {
Name: name,
TableType: 'EXTERNAL_TABLE',
...(description ? {Description: description} : {}),
Parameters: {
classification: format,
EXTERNAL: 'TRUE',
...(csv && input.headerRow ? {'skip.header.line.count': '1'} : {}),
},
StorageDescriptor: {
Columns: columns.map((column) => ({Name: column.name, Type: column.type})),
// Athena reads every file under the prefix, so the location is a directory.
Location: location.endsWith('/') ? location : `${location}/`,
InputFormat: storage.InputFormat,
OutputFormat: storage.OutputFormat,
SerdeInfo: {
SerializationLibrary: storage.SerializationLibrary,
...(csv ? {Parameters: {'field.delim': delimiter}} : {}),
},
},
},
}))
return this.detail(databaseName)
}
async deleteCatalogTable(databaseName: string, tableName: string): Promise<CloudResource> {
await this.glue.send(new DeleteTableCommand({DatabaseName: databaseName, Name: tableName}))
return this.detail(databaseName)
}
private async database(name: string): Promise<Database | null> {
try {
return (await this.glue.send(new GetDatabaseCommand({Name: name}))).Database ?? null
} catch (error) {
if (errorName(error) === 'EntityNotFoundException') return null
throw error
}
}
private async detail(name: string): Promise<CloudResource> {
const detail = await this.get(name)
if (!detail) throw new NotFoundError(`Database ${name} not found`)
return detail
}
private async tables(databaseName: string): Promise<Table[]> {
const found: Table[] = []
let next: string | undefined
do {
const res = await this.glue.send(new GetTablesCommand({DatabaseName: databaseName, MaxResults: 100, ...(next ? {NextToken: next} : {})}))
found.push(...(res.TableList ?? []))
next = res.NextToken
} while (next)
return found.sort((a, b) => (a.Name ?? '').localeCompare(b.Name ?? ''))
}
}
function toResource(database: Database, detail: {tableCount: number; tables?: Table[]}): CloudResource {
const name = database.Name ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'datacatalog',
type: 'glueDatabase',
region: null,
createdAt: database.CreateTime ? database.CreateTime.toISOString() : null,
metadata: {
description: database.Description || null,
locationUri: database.LocationUri || null,
catalogId: database.CatalogId ?? null,
tableCount: detail.tableCount,
...(detail.tables ? {tables: detail.tables.map(describeTable)} : {}),
},
}
}
/** What the panel shows for a table: its files, how they are read, and its columns. */
export function describeTable(table: Table) {
const storage: StorageDescriptor = table.StorageDescriptor ?? {}
const format = tableFormat(table)
return {
name: table.Name ?? '',
description: table.Description || null,
format,
location: storage.Location ?? null,
delimiter: format === 'csv' ? storage.SerdeInfo?.Parameters?.['field.delim'] ?? ',' : null,
headerRow: table.Parameters?.['skip.header.line.count'] === '1',
columns: (storage.Columns ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
partitionKeys: (table.PartitionKeys ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
tableType: table.TableType ?? null,
createdAt: table.CreateTime ? table.CreateTime.toISOString() : null,
updatedAt: table.UpdateTime ? table.UpdateTime.toISOString() : null,
}
}
/** The same order the runtime's Athena engine checks a table's storage in. */
function tableFormat(table: Table): string {
const classification = table.Parameters?.classification?.toLowerCase()
if (classification === 'iceberg' || table.Parameters?.table_type?.toLowerCase() === 'iceberg') return 'iceberg'
const storage = table.StorageDescriptor
const hints = `${storage?.InputFormat ?? ''} ${storage?.SerdeInfo?.SerializationLibrary ?? ''}`.toLowerCase()
if (hints.includes('parquet')) return 'parquet'
if (hints.includes('json')) return 'json'
return classification || 'csv'
}
function tableColumns(value: unknown): Array<{name: string; type: string}> {
if (!Array.isArray(value) || value.length === 0) throw new ValidationError('columns must list at least one column')
const seen = new Set<string>()
return value.map((column, index) => {
const entry = typeof column === 'object' && column !== null ? column as Record<string, unknown> : {}
const name = catalogName(entry.name, `columns[${index}].name`)
if (seen.has(name)) throw new ValidationError(`column ${name} is listed twice`)
seen.add(name)
const type = text(entry.type).toLowerCase()
if (!(CATALOG_COLUMN_TYPES as readonly string[]).includes(type) && !DECIMAL_TYPE.test(type)) {
throw new ValidationError(`columns[${index}].type must be one of ${CATALOG_COLUMN_TYPES.join(', ')}`)
}
return {name, type}
})
}
function catalogName(value: unknown, field: string): string {
const name = text(value)
if (!new RegExp(CATALOG_NAME_PATTERN).test(name)) throw new ValidationError(`${field} must be lowercase letters, numbers and underscores, starting with a letter or underscore`)
return name
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
+3
View File
@@ -38,6 +38,7 @@ import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
import { ACMClient } from "@aws-sdk/client-acm";
import { PipesClient } from "@aws-sdk/client-pipes";
import { EFSClient } from "@aws-sdk/client-efs";
import { GlueClient } from "@aws-sdk/client-glue";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -107,6 +108,7 @@ export type AwsClients = {
acm: ACMClient;
pipes: PipesClient;
efs: EFSClient;
glue: GlueClient;
};
export type AwsClientName = keyof AwsClients;
@@ -160,6 +162,7 @@ function buildClients(accountId: string): AwsClients {
acm: new ACMClient(base),
pipes: new PipesClient(base),
efs: new EFSClient(base),
glue: new GlueClient(base),
};
}
@@ -0,0 +1,68 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/**
* Glue's rule for database and table names. Glue lowercases them, and Athena
* needs them to start with a letter or underscore to query them unquoted.
*/
export const CATALOG_NAME_PATTERN = '^[a-z_][a-z0-9_]{0,254}$'
/** The data formats TheCloud's Athena engine reads from a table's S3 location. */
export const CATALOG_TABLE_FORMATS = ['csv', 'json', 'parquet'] as const
/** Hive column types Athena understands and the console offers. */
export const CATALOG_COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)'] as const
const databaseColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'tables', label: 'Tables', path: 'metadata.tableCount', emptyText: '—'},
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
{name: 'location', label: 'Location', path: 'metadata.locationUri', emptyText: '—'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const databaseFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsDataCatalogSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'datacatalog',
displayName: 'Glue Data Catalog databases',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
description: 'Lowercase letters, numbers and underscores, starting with a letter or underscore, so Athena can query it.',
validation: {pattern: CATALOG_NAME_PATTERN, message: 'Use lowercase letters, numbers and underscores, starting with a letter or underscore'},
},
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 2048}},
{
name: 'locationUri',
label: 'Location',
type: 'text',
required: false,
span: true,
description: 'Optional S3 location for the database, such as s3://my-bucket/warehouse/.',
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/warehouse/'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List databases', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create database', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete database', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect database', enabled: true, status: 'available', runtimeRequired: true},
],
dataCatalogActions: [
{name: 'createTable', label: 'Create table', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteTable', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: databaseFilters,
columns: databaseColumns,
}
}
export function dataCatalogSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsDataCatalogSchema() : null
}
@@ -222,6 +222,13 @@ export const SERVICE_CATALOG = {
group: 'Analytics',
order: 18,
},
datacatalog: {
displayName: 'Data Catalog',
displayNameByCloud: {aws: 'Glue Data Catalog'},
iconKey: 'datacatalog',
group: 'Analytics',
order: 20,
},
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
email: {
+22
View File
@@ -188,6 +188,7 @@ export type UserPoolActionName =
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type DataCatalogActionName = 'createTable' | 'deleteTable'
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName =
@@ -251,6 +252,7 @@ export interface ServiceSchema {
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
certificateActions?: CapabilitySchema<CertificateActionName>[]
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
@@ -558,6 +560,24 @@ export interface AccessPointInput {
permissions: string
}
export interface CatalogColumnInput {
name: string
type: string
}
export interface CatalogTableInput {
name: string
description?: string
/** The S3 prefix holding the table's files, such as s3://bucket/orders/. */
location: string
format: 'csv' | 'json' | 'parquet'
/** CSV only: the field separator, a comma unless given. */
delimiter?: string
/** CSV only: whether the first line of each file is a header to skip. */
headerRow?: boolean
columns: CatalogColumnInput[]
}
export interface FileSystemAttachmentInput {
accessPointId: string
functionName: string
@@ -1235,6 +1255,8 @@ export interface CloudServiceAdapter {
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
getCertificatePem?(certificateId: string): Promise<CertificatePem>
createCatalogTable?(databaseName: string, input: CatalogTableInput): Promise<CloudResource>
deleteCatalogTable?(databaseName: string, tableName: string): Promise<CloudResource>
createMountTarget?(fileSystemId: string, subnetId: string): Promise<CloudResource>
deleteMountTarget?(fileSystemId: string, mountTargetId: string): Promise<CloudResource>
createAccessPoint?(fileSystemId: string, input: AccessPointInput): Promise<CloudResource>
+16 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -81,6 +81,11 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
revoke: 'revokeCertificate',
}
const METHOD_FOR_DATA_CATALOG_ACTION: Record<DataCatalogActionName, keyof CloudServiceAdapter> = {
createTable: 'createCatalogTable',
deleteTable: 'deleteCatalogTable',
}
const METHOD_FOR_FILE_SYSTEM_ACTION: Record<FileSystemActionName, keyof CloudServiceAdapter> = {
createMountTarget: 'createMountTarget',
deleteMountTarget: 'deleteMountTarget',
@@ -226,6 +231,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const dataCatalogClaims = (adapter.schema().capabilities?.dataCatalogActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of dataCatalogClaims) {
const method = METHOD_FOR_DATA_CATALOG_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const fileSystemClaims = (adapter.schema().capabilities?.fileSystemActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of fileSystemClaims) {
const method = METHOD_FOR_FILE_SYSTEM_ACTION[capability.name]
@@ -259,6 +273,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.autoScalingActions ?? []),
...(adapter.schema().capabilities?.fileSystemActions ?? []),
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
...(adapter.schema().capabilities?.certificateActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
+2
View File
@@ -54,6 +54,7 @@ import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
@@ -111,6 +112,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
new AwsPipesAdapter(clients.pipes),
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
new AwsDataCatalogAdapter(clients.glue),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
+18
View File
@@ -20,6 +20,7 @@ import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
@@ -876,6 +877,23 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
})
test('creates and deletes catalog tables through the Glue adapter', async () => {
const calls: unknown[] = []
const database = {id: 'shop', name: 'shop', cloud: 'aws' as const, service: 'datacatalog' as const, type: 'glueDatabase', region: null, createdAt: null, metadata: {}}
const app = appWithRoutes([mockAdapter('aws', {
service: 'datacatalog',
schema: awsDataCatalogSchema,
createCatalogTable: async (id, input) => { calls.push(['create', id, input.name, input.format]); return database },
deleteCatalogTable: async (id, tableName) => { calls.push(['delete', id, tableName]); return database },
})])
const base = '/api/clouds/aws/services/datacatalog/resources/shop/tables'
const created = await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({name: 'orders', location: 's3://data/orders/', format: 'csv', columns: [{name: 'id', type: 'int'}]})})
expect(created.status).toBe(201)
expect((await app.request(`${base}/orders`, {method: 'DELETE'})).status).toBe(200)
expect(calls).toEqual([['create', 'shop', 'orders', 'csv'], ['delete', 'shop', 'orders']])
})
test('manages mount targets, access points and function mounts through the EFS adapter', async () => {
const calls: unknown[] = []
const fileSystem = {id: 'fs-1', name: 'shared', cloud: 'aws' as const, service: 'filesystems' as const, type: 'fileSystem', region: null, createdAt: null, metadata: {}}
+15
View File
@@ -4,6 +4,7 @@ import type {
AlarmStateValue,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
FileSystemAttachmentInput,
CertificateRevocationReason,
CloudProvider,
@@ -556,6 +557,20 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.post('/:cloud/services/datacatalog/resources/:id/tables', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).createCatalogTable(cloud, c.req.param('id'), await c.req.json<CatalogTableInput>()), 201))
})
app.delete('/:cloud/services/datacatalog/resources/:id/tables/:tableName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deleteCatalogTable(cloud, c.req.param('id'), c.req.param('tableName'))))
})
app.post('/:cloud/services/filesystems/resources/:id/mount-targets', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -95,6 +95,7 @@ import type {
DistributionInvalidation,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
FileSystemAttachmentInput,
CertificatePem,
CertificateRevocationReason,
@@ -966,6 +967,18 @@ export class CloudProxyService {
return adapter.revokeCertificate(certificateId, reason)
}
async createCatalogTable(cloud: CloudProvider, databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'datacatalog')
if (!adapter.createCatalogTable) throw new NotSupportedError(`Creating tables is not supported for ${cloud}/datacatalog`)
return adapter.createCatalogTable(databaseName, input)
}
async deleteCatalogTable(cloud: CloudProvider, databaseName: string, tableName: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'datacatalog')
if (!adapter.deleteCatalogTable) throw new NotSupportedError(`Deleting tables is not supported for ${cloud}/datacatalog`)
return adapter.deleteCatalogTable(databaseName, tableName)
}
async createMountTarget(cloud: CloudProvider, fileSystemId: string, subnetId: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'filesystems')
if (!adapter.createMountTarget) throw new NotSupportedError(`Mount targets are not supported for ${cloud}/filesystems`)
+18
View File
@@ -155,6 +155,10 @@ export const apiEndpointKeys = {
export: "clouds.services.certificates.export",
revoke: "clouds.services.certificates.revoke",
},
datacatalog: {
tables: "clouds.services.datacatalog.tables",
table: "clouds.services.datacatalog.table",
},
filesystems: {
mountTargets: "clouds.services.filesystems.mountTargets",
mountTarget: "clouds.services.filesystems.mountTarget",
@@ -964,6 +968,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[
apiEndpointKeys.clouds.datacatalog.tables,
{
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables",
method: "POST",
},
],
[
apiEndpointKeys.clouds.datacatalog.table,
{
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables/:tableName",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.filesystems.mountTargets,
{
@@ -35,6 +35,7 @@ import type {
DistributionInvalidation,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
FileSystemAttachmentInput,
CertificatePem,
CertificateRevocationReason,
@@ -1723,6 +1724,32 @@ export async function revokeCertificate(
return res.data;
}
export async function createCatalogTable(
cloud: CloudProvider,
databaseName: string,
input: CatalogTableInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, CatalogTableInput>(
apiEndpointKeys.clouds.datacatalog.tables,
requestOptions(cloud, "datacatalog", { body: input }),
{ cloud, id: databaseName },
);
return res.data;
}
export async function deleteCatalogTable(
cloud: CloudProvider,
databaseName: string,
tableName: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.datacatalog.table,
requestOptions(cloud, "datacatalog"),
{ cloud, id: databaseName, tableName },
);
return res.data;
}
export async function createMountTarget(
cloud: CloudProvider,
fileSystemId: string,
@@ -0,0 +1,90 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {DataCatalogPanel} from './DataCatalogPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
createCatalogTable: vi.fn(),
deleteCatalogTable: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function database(tables: unknown[] = [{
name: 'orders', description: null, format: 'csv', location: 's3://data/orders/', delimiter: ';', headerRow: true,
columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [], createdAt: null, updatedAt: null,
}]): CloudResource {
return {id: 'shop', name: 'shop', cloud: 'aws', service: 'datacatalog', type: 'glueDatabase', region: null, createdAt: null, metadata: {description: 'Orders', tableCount: tables.length, tables}}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.createCatalogTable.mockResolvedValue(database())
api.deleteCatalogTable.mockResolvedValue(database([]))
})
test('lists tables with their format, location and columns', async () => {
renderPanel(database())
const row = (await screen.findByText('orders')).closest('tr') as HTMLElement
expect(within(row).getByText('s3://data/orders/')).toBeInTheDocument()
expect(within(row).getByText('semicolon-separated, header row')).toBeInTheDocument()
expect(within(row).getByText('amount')).toBeInTheDocument()
})
test('creates a CSV table from the columns entered', async () => {
const user = userEvent.setup()
renderPanel(database([]))
const form = await screen.findByRole('form', {name: 'Create table'})
await user.type(within(form).getByLabelText('Name'), 'events')
const location = within(form).getByLabelText('S3 location')
await user.clear(location)
await user.type(location, 's3://data/events/')
await user.type(within(form).getByLabelText('Column 1 name'), 'id')
await user.selectOptions(within(form).getByLabelText('Column 1 type'), 'int')
await user.click(within(form).getByRole('button', {name: 'Add column'}))
await user.type(within(form).getByLabelText('Column 2 name'), 'kind')
await user.click(within(form).getByRole('button', {name: 'Create table'}))
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {
name: 'events', location: 's3://data/events/', format: 'csv', delimiter: ',', headerRow: true,
columns: [{name: 'id', type: 'int'}, {name: 'kind', type: 'string'}],
})
expect(await screen.findByText('Created table events. Athena can query it now.')).toBeInTheDocument()
})
test('JSON tables need no delimiter or header', async () => {
const user = userEvent.setup()
renderPanel(database([]))
const form = await screen.findByRole('form', {name: 'Create table'})
await user.selectOptions(within(form).getByLabelText('Format'), 'json')
expect(within(form).queryByLabelText('Delimiter')).not.toBeInTheDocument()
await user.type(within(form).getByLabelText('Name'), 'events')
await user.type(within(form).getByLabelText('S3 location'), 'data/events/')
await user.type(within(form).getByLabelText('Column 1 name'), 'kind')
await user.click(within(form).getByRole('button', {name: 'Create table'}))
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
})
test('deletes a table after confirming', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(database())
await user.click(await screen.findByRole('button', {name: 'Delete table orders'}))
expect(api.deleteCatalogTable).toHaveBeenCalledWith('aws', 'shop', 'orders')
expect(await screen.findByText('Deleted table orders. Its files stay in S3.')).toBeInTheDocument()
})
@@ -0,0 +1,145 @@
import {Plus, RefreshCw, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CatalogTableInput, CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type Column = {name: string; type: string}
type CatalogTable = {
name: string
description: string | null
format: string
location: string | null
delimiter: string | null
headerRow: boolean
columns: Column[]
partitionKeys: Column[]
updatedAt: string | null
createdAt: string | null
}
type TableForm = {name: string; location: string; format: CatalogTableInput['format']; delimiter: string; headerRow: boolean; columns: Column[]}
/** Matches the types the API accepts; decimal(p,s) can also be typed in by hand through the API. */
const COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)']
const FORMAT_LABELS: Record<string, string> = {csv: 'CSV', json: 'JSON lines', parquet: 'Parquet', iceberg: 'Iceberg'}
const DELIMITER_NAMES: Record<string, string> = {',': 'comma-separated', ';': 'semicolon-separated', '\t': 'tab-separated', '|': 'pipe-separated'}
const delimiterLabel = (delimiter: string | null) => DELIMITER_NAMES[delimiter ?? ','] ?? `separated by "${delimiter}"`
const emptyForm = (): TableForm => ({name: '', location: 's3://', format: 'csv', delimiter: ',', headerRow: true, columns: [{name: '', type: 'string'}]})
export function DataCatalogPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [form, setForm] = useState<TableForm>(emptyForm)
const [notice, setNotice] = useState('')
const detailKey = ['glue-database', accountId, resource?.id]
const detailQuery = useQuery({
queryKey: detailKey,
queryFn: ({signal}) => getCloudResource('aws', 'datacatalog', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setNotice('')
setForm(emptyForm())
}, [resource?.id])
const settle = (updated: CloudResource, message: string) => {
queryClient.setQueryData(detailKey, updated)
setNotice(message)
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'datacatalog')})
}
const createTable = useMutation({
mutationFn: () => createCatalogTable('aws', resource?.id ?? '', {
name: form.name.trim(),
location: form.location.trim(),
format: form.format,
...(form.format === 'csv' ? {delimiter: form.delimiter, headerRow: form.headerRow} : {}),
columns: form.columns.map((column) => ({name: column.name.trim(), type: column.type})),
}),
onSuccess: (updated) => {
const name = form.name.trim()
setForm(emptyForm())
return settle(updated, `Created table ${name}. Athena can query it now.`)
},
})
const deleteTable = useMutation({
mutationFn: (tableName: string) => deleteCatalogTable('aws', resource?.id ?? '', tableName),
onSuccess: (updated, tableName) => settle(updated, `Deleted table ${tableName}. Its files stay in S3.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a database</h3><p>Choose a database to see its tables, or describe files in S3 as a new table.</p></div></section>
}
const database = detailQuery.data ?? resource
const tables = Array.isArray(database.metadata.tables) ? database.metadata.tables as CatalogTable[] : []
const disabled = !runtimeReachable || createTable.isPending || deleteTable.isPending
const error = detailQuery.error ?? createTable.error ?? deleteTable.error
const setColumn = (index: number, patch: Partial<Column>) => setForm({...form, columns: form.columns.map((column, i) => i === index ? {...column, ...patch} : column)})
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Database ${database.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Glue database</p><span className="muted">{database.metadata.description ? String(database.metadata.description) : 'Tables here describe files in S3 so Athena can query them.'}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Tables</p><span className="muted">{tables.length === 1 ? '1 table' : `${tables.length} tables`}</span></div></div>
{tables.length === 0 ? <p className="muted compact-text catalog-note">No tables yet. Describe a folder of CSV, JSON or Parquet files below.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Table</th><th>Format</th><th>Location</th><th>Columns</th><th>Updated</th><th aria-label="Actions"/></tr></thead><tbody>{tables.map((table) => (
<tr key={table.name}>
<td className="mono">{table.name}{table.description && <><br/><small className="muted">{table.description}</small></>}</td>
<td>{FORMAT_LABELS[table.format] ?? table.format}{table.format === 'csv' && <><br/><small className="muted">{delimiterLabel(table.delimiter)}{table.headerRow ? ', header row' : ''}</small></>}</td>
<td className="mono catalog-location">{table.location ?? '—'}</td>
<td className="catalog-columns">{table.columns.map((column) => <span key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></span>)}
{table.partitionKeys.length > 0 && <small className="muted">partitioned by {table.partitionKeys.map((key) => key.name).join(', ')}</small>}</td>
<td>{formatDateTime(table.updatedAt ?? table.createdAt) ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete table ${table.name}`} onClick={() => {
setNotice('')
if (window.confirm(`Delete table ${table.name}? Its files stay in S3.`)) deleteTable.mutate(table.name)
}}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="catalog-form" aria-label="Create table" onSubmit={(event) => { event.preventDefault(); setNotice(''); createTable.mutate() }}>
<p className="eyebrow">New table</p>
<div className="dynamic-form catalog-form-fields">
<label className="dynamic-field"><span>Name</span><input className="input mono" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} disabled={disabled} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/></label>
<label className="dynamic-field catalog-span"><span>S3 location</span><input className="input mono" value={form.location} onChange={(event) => setForm({...form, location: event.target.value})} disabled={disabled} required pattern="s3://[a-z0-9.\-]{3,63}(/.*)?" title="A folder such as s3://my-bucket/orders/"/></label>
<label className="dynamic-field"><span>Format</span><select className="input" value={form.format} onChange={(event) => setForm({...form, format: event.target.value as TableForm['format']})} disabled={disabled}>
<option value="csv">CSV</option><option value="json">JSON lines</option><option value="parquet">Parquet</option>
</select></label>
{form.format === 'csv' && <>
<label className="dynamic-field"><span>Delimiter</span><input className="input mono" value={form.delimiter} maxLength={1} onChange={(event) => setForm({...form, delimiter: event.target.value})} disabled={disabled} required/></label>
<label className="dynamic-field catalog-check"><input type="checkbox" checked={form.headerRow} onChange={(event) => setForm({...form, headerRow: event.target.checked})} disabled={disabled}/><span>First line is a header</span></label>
</>}
</div>
<fieldset className="catalog-columns-editor" disabled={disabled}>
<legend>Columns</legend>
{form.columns.map((column, index) => <div className="catalog-column-row" key={index}>
<input className="input mono" aria-label={`Column ${index + 1} name`} value={column.name} onChange={(event) => setColumn(index, {name: event.target.value})} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/>
<select className="input" aria-label={`Column ${index + 1} type`} value={column.type} onChange={(event) => setColumn(index, {type: event.target.value})}>
{COLUMN_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
<button className="button compact" type="button" aria-label={`Remove column ${index + 1}`} disabled={form.columns.length === 1} onClick={() => setForm({...form, columns: form.columns.filter((_, i) => i !== index)})}><X size={14}/></button>
</div>)}
<button className="button compact" type="button" onClick={() => setForm({...form, columns: [...form.columns, {name: '', type: 'string'}]})}><Plus size={14}/>Add column</button>
</fieldset>
<p className="muted compact-text">List the columns in the order they appear in the files. For JSON, use the field names.</p>
<div><button className="button primary" type="submit" disabled={disabled}>{createTable.isPending ? 'Creating' : 'Create table'}</button></div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
@@ -70,6 +70,7 @@ import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
import { FileSystemPanel } from "@/components/FileSystemPanel";
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
import { CertificatePanel } from "@/components/CertificatePanel";
import { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
@@ -1003,6 +1004,9 @@ export function DynamicResourceView({
{service === "filesystems" && cloud === "aws" && (
<FileSystemPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "datacatalog" && cloud === "aws" && (
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
@@ -41,6 +41,7 @@ import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/R
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
import efsIcon from '../../../../aws-icons-svg/Storage/EFS.svg'
import glueIcon from '../../../../aws-icons-svg/Analytics/Glue.svg'
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
@@ -104,6 +105,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
cdn: Earth,
autoscaling: Scaling,
filesystems: FolderTree,
datacatalog: Table2,
certificates: FileBadge,
monitoring: Activity,
loadbalancing: Scale,
@@ -155,6 +157,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
cdn: cloudFrontIcon,
autoscaling: autoScalingIcon,
filesystems: efsIcon,
datacatalog: glueIcon,
certificates: certificateManagerIcon,
monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon,
+18
View File
@@ -6636,3 +6636,21 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.efs-inline-form { grid-template-columns: minmax(180px, 320px) auto; align-items: end; padding: 4px 16px 12px; }
.efs-access-point-form { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); align-items: end; padding: 4px 16px 12px; }
.efs-row-actions { text-align: right; }
/* Glue Data Catalog: column chips in the tables list, a compact column editor. */
.catalog-note { padding: 0 16px 12px; margin: 0; }
.catalog-location { overflow-wrap: anywhere; }
.catalog-columns { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.catalog-columns > small { flex-basis: 100%; }
.catalog-form { display: grid; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border); }
.catalog-form p { margin: 0; }
.catalog-form-fields { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; }
.catalog-span { grid-column: span 2; }
.catalog-check { display: flex; align-items: center; gap: 8px; min-height: 30px; }
.catalog-check input { width: auto; }
.catalog-columns-editor { display: grid; gap: 6px; border: 0; padding: 0; margin: 0; min-width: 0; }
.catalog-columns-editor legend { color: var(--text-2); font-size: 11px; font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.catalog-column-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; gap: 6px; max-width: 560px; }
.catalog-column-row .input { width: 100%; min-width: 0; }
.catalog-columns-editor > .button { justify-self: start; }
@media (max-width: 640px) { .catalog-span { grid-column: auto; } }
+15
View File
@@ -362,6 +362,21 @@ export interface AutoScalingCapacityInput {
maxSize?: number
}
export interface CatalogColumnInput {
name: string
type: string
}
export interface CatalogTableInput {
name: string
description?: string
location: string
format: 'csv' | 'json' | 'parquet'
delimiter?: string
headerRow?: boolean
columns: CatalogColumnInput[]
}
export interface AccessPointInput {
name?: string
path: string
+2
View File
@@ -30,6 +30,7 @@ export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type DataCatalogActionName = 'createTable' | 'deleteTable'
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
@@ -97,6 +98,7 @@ export interface ServiceSchema {
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
dataCatalogActions?: Array<CapabilitySchema<DataCatalogActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
}
filters: FieldSchema[]
+18
View File
@@ -76,6 +76,9 @@ importers:
'@aws-sdk/client-eventbridge':
specifier: ^3.1076.0
version: 3.1121.0
'@aws-sdk/client-glue':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-iam':
specifier: ^3.1076.0
version: 3.1120.0
@@ -398,6 +401,10 @@ packages:
resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-glue@3.1156.0':
resolution: {integrity: sha512-IkH03UtDnHWpbcdN11bWOr4XeS7F13veZ3/1ukRPznRwtrtXBVC3SivkQVJEeQ18stsKqgX3xMOK8UZH9imiiQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-iam@3.1120.0':
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
engines: {node: '>=20.0.0'}
@@ -3178,6 +3185,17 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-glue@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@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-iam@3.1120.0':
dependencies:
'@aws-sdk/core': 3.977.9