first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -0,0 +1,49 @@
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create API', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete API', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
]
export function awsApiGatewaySchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'apigateway',
displayName: 'AWS API Gateway',
fields: [
{
name: 'name',
label: 'API Name',
type: 'text',
required: true,
description: '1-1024 characters.',
validation: {
minLength: 1,
maxLength: 1024,
message: 'API name is required and must be 1-1024 characters.',
},
},
{
name: 'description',
label: 'Description',
type: 'text',
required: false,
description: 'Optional description for the REST API.',
validation: {
maxLength: 1024,
message: 'Description must be 1024 characters or fewer.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'API Name'},
{name: 'type', label: 'Type'},
{name: 'createdAt', label: 'Created At'},
],
}
}
@@ -0,0 +1,36 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const applicationColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
{name: 'id', label: 'Application Id'},
]
const applicationFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
export function awsAppConfigSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'configuration',
displayName: 'AppConfig',
fields: [
{
name: 'name',
label: 'Application Name',
type: 'text',
required: true,
validation: {
pattern: '^[A-Za-z0-9._\\-]+$',
maxLength: 255,
message: 'Use letters, numbers, periods, hyphens, or underscores.',
},
},
{name: 'description', label: 'Description', type: 'textarea', required: false},
],
actions: ['list', 'create', 'delete', 'inspect'],
filters: applicationFilters,
columns: applicationColumns,
}
}
@@ -0,0 +1,115 @@
import {describe, expect, test} from 'bun:test'
import {checkChildCapabilities} from './childCapabilities'
import type {ChildCollection, ChildItem, CollectionPage, DocumentStoreAdapter} from './childCollections'
import type {CloudServiceAdapter, ServiceSchema} from './types'
const emptyCollections: CollectionPage<ChildCollection> = {items: [], nextCursor: null}
const emptyItems: CollectionPage<ChildItem> = {items: [], nextCursor: null}
function schemaWith(capabilities: ServiceSchema['capabilities']): ServiceSchema {
return {
cloud: 'aws',
service: 'storage',
displayName: 'test',
fields: [],
actions: ['list'],
capabilities,
filters: [],
columns: [],
}
}
function adapterWith(
capabilities: ServiceSchema['capabilities'],
documents?: DocumentStoreAdapter,
items?: CloudServiceAdapter['items'],
): CloudServiceAdapter {
return {
cloud: 'aws',
service: 'storage',
schema: () => schemaWith(capabilities),
list: async () => [],
get: async () => null,
create: async () => {
throw new Error('unused')
},
delete: async () => {},
documents,
items,
}
}
const listOnlyDocuments: DocumentStoreAdapter = {
listCollections: async () => emptyCollections,
listItems: async () => emptyItems,
}
describe('checkChildCapabilities', () => {
test('an adapter with no child store and no child capabilities is consistent', () => {
expect(checkChildCapabilities(adapterWith(undefined))).toEqual([])
})
// CloudWatch Logs: streams are creatable, events are strictly read-only.
test('a read-only leaf is a legitimate declared state', () => {
const adapter = adapterWith(
{
collectionActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
},
listOnlyDocuments,
)
expect(checkChildCapabilities(adapter)).toEqual([])
})
test('claiming an unimplemented capability is a violation', () => {
const adapter = adapterWith(
{
collectionActions: [{name: 'create', label: 'Create', enabled: true, status: 'available'}],
itemActions: [],
},
listOnlyDocuments,
)
expect(checkChildCapabilities(adapter)).toEqual([
'collectionActions.create is available but documents.createCollection is missing',
'documents.listCollections is implemented but collectionActions.list is not advertised',
'documents.listItems is implemented but itemActions.list is not advertised',
])
})
test('an implemented write that is never advertised is a violation', () => {
const adapter = adapterWith(
{
collectionActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
},
{...listOnlyDocuments, deleteCollection: async () => {}},
)
expect(checkChildCapabilities(adapter)).toEqual([
'documents.deleteCollection is implemented but collectionActions.delete is not advertised',
])
})
test('declaring capabilities with no child store at all is a violation', () => {
const adapter = adapterWith({
itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
})
expect(checkChildCapabilities(adapter)).toEqual([
'itemActions are declared but neither documents nor items is implemented',
])
})
// The routes cannot decide which shape `/resources/:id/items` addresses.
test('declaring both stores is a violation', () => {
const adapter = adapterWith(
{
collectionActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}],
},
listOnlyDocuments,
{listItems: async () => emptyItems},
)
expect(checkChildCapabilities(adapter)).toContain(
'declares both documents and items; a store is either nested or flat',
)
})
})
@@ -0,0 +1,89 @@
/**
* Keeps a child store and its advertised capabilities honest in both
* directions: nothing claimed that is not implemented, nothing implemented that
* is not claimed.
*
* Lives apart from cloudProxy.test.ts so the logic is unit-testable against
* fakes rather than only against whatever adapters happen to be registered.
*/
import type {CollectionActionName, ItemActionName} from './childCollections'
import type {CloudServiceAdapter} from './types'
const COLLECTION_METHODS: Record<CollectionActionName, string> = {
list: 'listCollections',
create: 'createCollection',
delete: 'deleteCollection',
}
const ITEM_METHODS: Record<ItemActionName, string> = {
list: 'listItems',
get: 'getItem',
put: 'putItem',
delete: 'deleteItem',
query: 'queryItems',
}
export function checkChildCapabilities(adapter: CloudServiceAdapter): string[] {
const violations: string[] = []
const capabilities = adapter.schema().capabilities
const collectionActions = capabilities?.collectionActions
const itemActions = capabilities?.itemActions
const store = adapter.documents ?? adapter.items
const storeName = adapter.documents ? 'documents' : 'items'
if (adapter.documents && adapter.items) {
violations.push('declares both documents and items; a store is either nested or flat')
}
if (!store) {
if (collectionActions) {
violations.push('collectionActions are declared but neither documents nor items is implemented')
}
if (itemActions) {
violations.push('itemActions are declared but neither documents nor items is implemented')
}
return violations
}
// Collection-level verbs only exist on the nested shape.
if (adapter.documents) {
violations.push(
...compare('collectionActions', 'documents', COLLECTION_METHODS, collectionActions ?? [], adapter.documents),
)
} else if (collectionActions?.length) {
violations.push('collectionActions are declared but the store is flat')
}
violations.push(...compare('itemActions', storeName, ITEM_METHODS, itemActions ?? [], store))
return violations
}
function compare(
capabilityField: string,
storeName: string,
methods: Record<string, string>,
declared: Array<{name: string; status: string}>,
store: object,
): string[] {
const violations: string[] = []
const implemented = (method: string) => typeof (store as Record<string, unknown>)[method] === 'function'
for (const capability of declared) {
if (capability.status !== 'available') continue
const method = methods[capability.name]
if (!implemented(method)) {
violations.push(`${capabilityField}.${capability.name} is available but ${storeName}.${method} is missing`)
}
}
const advertised = new Set(declared.map((capability) => capability.name))
for (const [action, method] of Object.entries(methods)) {
if (implemented(method) && !advertised.has(action)) {
violations.push(`${storeName}.${method} is implemented but ${capabilityField}.${action} is not advertised`)
}
}
return violations
}
@@ -0,0 +1,25 @@
import {describe, expect, test} from 'bun:test'
import {ValidationError} from './errors'
import {DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, clampLimit} from './childCollections'
describe('clampLimit', () => {
test('defaults when the caller sends nothing', () => {
expect(clampLimit(undefined)).toBe(DEFAULT_PAGE_LIMIT)
expect(clampLimit('')).toBe(DEFAULT_PAGE_LIMIT)
})
test('accepts a value inside the range', () => {
expect(clampLimit('25')).toBe(25)
expect(clampLimit(String(MAX_PAGE_LIMIT))).toBe(MAX_PAGE_LIMIT)
})
// Rejecting rather than silently clamping: a truncated page that looks
// complete is the failure mode this contract exists to avoid.
test('rejects out-of-range and non-numeric values', () => {
expect(() => clampLimit('0')).toThrow(ValidationError)
expect(() => clampLimit('-1')).toThrow(ValidationError)
expect(() => clampLimit(String(MAX_PAGE_LIMIT + 1))).toThrow(ValidationError)
expect(() => clampLimit('abc')).toThrow(ValidationError)
expect(() => clampLimit('1.5')).toThrow(ValidationError)
})
})
@@ -0,0 +1,91 @@
/**
* Vendor-neutral child collections.
*
* Two shapes, because the services that need this genuinely differ in depth: a
* CloudWatch log group holds streams which hold events (two levels), while a
* DynamoDB table simply is the collection (flat). Collapsing them into one
* interface would put a "which depth am I" branch in every implementer.
*
* `ChildItem.body` is where the vendor payload lands — a log event, a Cosmos
* document, a DynamoDB item — so no vendor concept reaches this contract.
*/
import {ValidationError} from './errors'
import type {CreateResourceInput} from './types'
export const DEFAULT_PAGE_LIMIT = 100
export const MAX_PAGE_LIMIT = 1000
export interface CollectionPage<T> {
items: T[]
/**
* Opaque continuation token; null when the page is the last one. Never
* round-trip a cursor to a different resource or collection.
*/
nextCursor: string | null
}
export interface PageQuery {
cursor?: string
limit?: number
}
/** The middle level: a log stream, a Cosmos container, a BigQuery table. */
export interface ChildCollection {
id: string
name: string
parentId: string
createdAt: string | null
metadata: Record<string, unknown>
}
/** The leaf: a log event, a Cosmos document, a DynamoDB item. */
export interface ChildItem {
id: string
/** null for flat stores, where the resource is the collection. */
collectionId: string | null
timestamp: string | null
body: Record<string, unknown>
metadata: Record<string, unknown>
}
export type CollectionActionName = 'list' | 'create' | 'delete'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
/** Two-level store: resource → collections → items. */
export interface DocumentStoreAdapter {
listCollections(resourceId: string, page?: PageQuery): Promise<CollectionPage<ChildCollection>>
createCollection?(resourceId: string, input: CreateResourceInput): Promise<ChildCollection>
deleteCollection?(resourceId: string, collectionId: string): Promise<void>
listItems(resourceId: string, collectionId: string, page?: PageQuery): Promise<CollectionPage<ChildItem>>
getItem?(resourceId: string, collectionId: string, itemId: string): Promise<ChildItem | null>
putItem?(resourceId: string, collectionId: string, body: Record<string, unknown>): Promise<ChildItem>
deleteItem?(resourceId: string, collectionId: string, itemId: string, partitionKey?: string | null): Promise<void>
queryItems?(resourceId: string, collectionId: string, query: string): Promise<CollectionPage<ChildItem>>
}
/** Flat store: the resource is the collection. */
export interface ItemStoreAdapter {
listItems(resourceId: string, page?: PageQuery): Promise<CollectionPage<ChildItem>>
getItem?(resourceId: string, itemId: string): Promise<ChildItem | null>
putItem?(resourceId: string, body: Record<string, unknown>): Promise<ChildItem>
deleteItem?(resourceId: string, itemId: string, partitionKey?: string | null): Promise<void>
queryItems?(resourceId: string, query: string): Promise<CollectionPage<ChildItem>>
}
/**
* Parse `?limit=`. Out-of-range values raise rather than clamp, so a caller
* asking for 5000 rows learns the ceiling instead of receiving 1000 that look
* like the whole set.
*/
export function clampLimit(raw: string | undefined): number {
if (raw === undefined || raw === '') return DEFAULT_PAGE_LIMIT
if (!/^\d+$/.test(raw)) {
throw new ValidationError(`limit must be a whole number between 1 and ${MAX_PAGE_LIMIT}.`)
}
const value = Number(raw)
if (value < 1 || value > MAX_PAGE_LIMIT) {
throw new ValidationError(`limit must be between 1 and ${MAX_PAGE_LIMIT}.`)
}
return value
}
@@ -0,0 +1,59 @@
import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
const cloudformationColumns: TableColumnSchema[] = [
{name: 'name', label: 'Stack Name'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'createdAt', label: 'Created At', format: 'datetime'},
]
const cloudformationFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
const cloudformationResourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List stacks', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create stack', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete stack', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect stack', enabled: true, status: 'available', runtimeRequired: false},
]
export function awsCloudformationSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'iac',
displayName: 'CloudFormation',
fields: [
{
name: 'stackName',
label: 'Stack Name',
type: 'text',
required: true,
description: 'Must start with a letter. Letters, numbers, and hyphens, up to 128 characters.',
validation: {
pattern: '^[a-zA-Z][a-zA-Z0-9\\-]{0,127}$',
maxLength: 128,
message: 'Use a valid stack name: start with a letter, then letters, numbers, or hyphens.',
},
},
{
name: 'templateBody',
label: 'Template Body',
type: 'text',
required: true,
span: true,
description: 'Single-line CloudFormation template JSON — the console form has no multi-line editor yet.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: cloudformationResourceActions,
},
filters: cloudformationFilters,
columns: cloudformationColumns,
}
}
export function cloudformationSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'aws') return awsCloudformationSchema()
return null
}
+248
View File
@@ -0,0 +1,248 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const computeColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'type', label: 'Type'},
{name: 'status', label: 'State'},
{name: 'region', label: 'AZ'},
{name: 'createdAt', label: 'Created'},
]
const computeFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
const computeFields: FieldSchema[] = [
// ── Required ──────────────────────────────────────────────────────
{
name: 'name',
label: 'Instance Name',
type: 'text',
required: true,
span: true,
group: 'Required',
validation: {minLength: 1, maxLength: 255, message: 'Provide a name for the instance.'},
},
{
name: 'imageId',
label: 'AMI ID',
type: 'text',
required: true,
description: 'e.g. ami-0abcdef1234567890',
validation: {
pattern: '^ami-[0-9a-f]{8,17}$',
message: 'Must be a valid AMI ID (ami-xxxxxxxxx).',
},
},
{
name: 'instanceType',
label: 'Instance Type',
type: 'select',
required: true,
options: [
{label: 't2.micro', value: 't2.micro'},
{label: 't2.small', value: 't2.small'},
{label: 't2.medium', value: 't2.medium'},
{label: 't3.micro', value: 't3.micro'},
{label: 't3.small', value: 't3.small'},
{label: 't3.medium', value: 't3.medium'},
{label: 't3.large', value: 't3.large'},
{label: 't3.xlarge', value: 't3.xlarge'},
{label: 'm5.large', value: 'm5.large'},
{label: 'm5.xlarge', value: 'm5.xlarge'},
{label: 'c5.large', value: 'c5.large'},
{label: 'c5.xlarge', value: 'c5.xlarge'},
],
},
// ── Networking (optional) ─────────────────────────────────────────
{
name: 'keyName',
label: 'Key Pair',
type: 'text',
required: false,
group: 'Networking — optional',
description: 'Name of an existing EC2 key pair.',
},
{
name: 'subnetId',
label: 'Subnet ID',
type: 'text',
required: false,
description: 'e.g. subnet-0abcdef1234567890',
},
{
name: 'securityGroupIds',
label: 'Security Group IDs',
type: 'text',
required: false,
span: true,
description: 'One or more SG IDs separated by commas — e.g. sg-111, sg-222',
},
]
/**
* Azure VM images, keyed by the label offered in the create form.
*
* The ARM API wants a four-part `imageReference`; asking a user for four fields to
* launch a VM is worse than a short list of known-good images. Shared with the
* adapter so the form and the request cannot drift.
*/
export const AZURE_VM_IMAGES = {
'Ubuntu 20.04 LTS': {publisher: 'Canonical', offer: 'UbuntuServer', sku: '20.04-LTS', version: 'latest'},
'Ubuntu 22.04 LTS': {
publisher: 'Canonical',
offer: '0001-com-ubuntu-server-jammy',
sku: '22_04-lts',
version: 'latest',
},
'Debian 11': {publisher: 'Debian', offer: 'debian-11', sku: '11', version: 'latest'},
'Windows Server 2022': {
publisher: 'MicrosoftWindowsServer',
offer: 'WindowsServer',
sku: '2022-datacenter',
version: 'latest',
},
} as const
export const AZURE_VM_SIZES = [
'Standard_B1s',
'Standard_B2s',
'Standard_D2s_v3',
'Standard_D4s_v3',
'Standard_F2s_v2',
] as const
export const AZURE_LOCATIONS = ['eastus', 'eastus2', 'westus', 'westus2', 'westeurope', 'northeurope'] as const
/** Azure VM names: 1-64 chars, letters, digits, hyphens, underscores and periods. */
export const AZURE_VM_NAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$'
export function azureComputeSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'compute',
displayName: 'Azure Virtual Machines',
fields: [
{
name: 'name',
label: 'VM Name',
type: 'text',
required: true,
span: true,
group: 'Required',
validation: {
pattern: AZURE_VM_NAME_PATTERN,
minLength: 1,
maxLength: 64,
message: 'Start with a letter or digit; letters, digits, hyphens, underscores and periods only.',
},
},
{
name: 'resourceGroup',
label: 'Resource Group',
type: 'text',
required: true,
group: 'Required',
description: 'Must already exist. Every Azure resource lives in a resource group.',
},
{
name: 'vmSize',
label: 'VM Size',
type: 'select',
required: true,
options: AZURE_VM_SIZES.map((value) => ({label: value, value})),
},
{
name: 'image',
label: 'Image',
type: 'select',
required: true,
options: Object.keys(AZURE_VM_IMAGES).map((value) => ({label: value, value})),
},
{
name: 'location',
label: 'Location',
type: 'select',
required: false,
group: 'Optional',
description: "The VM's own location. Defaults to eastus.",
options: AZURE_LOCATIONS.map((value) => ({label: value, value})),
},
{
name: 'adminUsername',
label: 'Admin Username',
type: 'text',
required: false,
group: 'Optional',
description: 'Defaults to azureuser.',
},
],
actions: ['list', 'inspect', 'create', 'delete'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create VM', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete VM', enabled: true, status: 'available', runtimeRequired: true},
// The runtime and the adapter both do these — verified against
// floci-az, where powerOff/start/restart really change power state.
// There is no generic actions route yet (only invoke/objects), so
// the console cannot reach them; saying `coming_soon` with the
// reason beats advertising a control that cannot be called.
{
name: 'start',
label: 'Start',
enabled: false,
status: 'coming_soon',
reason: 'The adapter implements start, but the generic resource actions route is not wired yet.',
runtimeRequired: true,
},
{
name: 'stop',
label: 'Stop',
enabled: false,
status: 'coming_soon',
reason: 'The adapter implements stop, but the generic resource actions route is not wired yet.',
runtimeRequired: true,
},
{
name: 'reboot',
label: 'Restart',
enabled: false,
status: 'coming_soon',
reason: 'The adapter implements reboot, but the generic resource actions route is not wired yet.',
runtimeRequired: true,
},
],
},
filters: computeFilters,
columns: [
{name: 'name', label: 'Name'},
{name: 'status', label: 'Power State'},
{name: 'instanceClass', label: 'Size'},
{name: 'region', label: 'Location'},
{name: 'resourceGroup', label: 'Resource Group', path: 'metadata.resourceGroup'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
],
}
}
export function awsComputeSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'compute',
displayName: 'Compute',
fields: computeFields,
actions: ['list', 'inspect', 'create', 'delete'],
filters: computeFilters,
columns: computeColumns,
capabilities: {
resourceActions: [
{name: 'list', label: 'List', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Launch instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Terminate', enabled: true, status: 'available', runtimeRequired: true},
],
},
}
}
@@ -0,0 +1,69 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const containerColumns: TableColumnSchema[] = [
{name: 'name', label: 'Service'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'image', label: 'Image', path: 'metadata.image', format: 'code', emptyText: '—'},
{name: 'uri', label: 'URL', path: 'metadata.uri', emptyText: '—'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const containerFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
/** Cloud Run service names: lowercase letters, digits and hyphens, max 49 chars. */
export const CLOUD_RUN_NAME_PATTERN = '^[a-z]([-a-z0-9]*[a-z0-9])?$'
export const CLOUD_RUN_NAME_MAX_LENGTH = 49
export function gcpContainersSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'containers',
displayName: 'Cloud Run',
fields: [
{
name: 'name',
label: 'Service Name',
type: 'text',
required: true,
validation: {
pattern: CLOUD_RUN_NAME_PATTERN,
minLength: 1,
maxLength: CLOUD_RUN_NAME_MAX_LENGTH,
message: 'Start with a letter; lowercase letters, digits and hyphens only.',
},
},
{
name: 'image',
label: 'Container Image',
type: 'text',
required: true,
span: true,
// The most common cause of a service that never becomes ready.
description:
'The container must listen on the port given by the PORT environment variable (8080 by default). An image that listens elsewhere will report a failed condition.',
},
{
name: 'port',
label: 'Container Port',
type: 'text',
required: false,
description: 'Overrides the port passed to the container. Defaults to 8080.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List services', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Deploy service', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete service', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect service', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: containerFilters,
columns: containerColumns,
}
}
export function containersSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'gcp' ? gcpContainersSchema() : null
}
@@ -0,0 +1,328 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const databaseColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'Status'},
{name: 'engine', label: 'Engine'},
{name: 'version', label: 'Version'},
{name: 'instanceClass', label: 'Class'},
]
/** Cloud SQL reports a connection endpoint, which RDS does not surface here. */
const cloudSqlColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'engine', label: 'Version'},
{name: 'region', label: 'Region'},
{name: 'instanceClass', label: 'Tier'},
{name: 'connectionName', label: 'Connection', path: 'metadata.connectionName', format: 'code'},
]
const databaseFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
export function awsDatabaseSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'database',
displayName: 'AWS RDS',
fields: [
{
name: 'dbInstanceIdentifier',
label: 'DB Instance Identifier',
type: 'text',
required: true,
validation: {
pattern: '^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$',
minLength: 1,
maxLength: 63,
},
},
{
name: 'engine',
label: 'Engine',
type: 'select',
required: true,
defaultValue: 'postgres',
options: [
{label: 'PostgreSQL', value: 'postgres'},
{label: 'MySQL', value: 'mysql'},
{label: 'MariaDB', value: 'mariadb'},
],
},
{
name: 'masterUserPassword',
label: 'Master User Password',
type: 'password',
required: true,
span: true,
validation: {minLength: 8, maxLength: 128},
},
{
name: 'dbInstanceClass',
label: 'DB Instance Class',
type: 'text',
required: false,
defaultValue: 'db.t3.micro',
},
{
name: 'allocatedStorage',
label: 'Allocated Storage',
type: 'text',
required: false,
defaultValue: '20',
validation: {pattern: '^[1-9][0-9]*$'},
},
{
name: 'masterUsername',
label: 'Master Username',
type: 'text',
required: false,
defaultValue: 'root',
validation: {maxLength: 63},
},
{
name: 'dbName',
label: 'Database Name',
type: 'text',
required: false,
defaultValue: '',
validation: {maxLength: 64},
},
{
name: 'engineVersion',
label: 'Engine Version',
type: 'text',
required: false,
defaultValue: '',
},
{
name: 'securityGroupIds',
label: 'Security Group IDs',
type: 'text',
required: false,
span: true,
description: 'One or more SG IDs separated by commas — e.g. sg-111, sg-222',
},
],
actions: ['list', 'create', 'update', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List DB instances', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'update', label: 'Update DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect DB instance', enabled: true, status: 'available', runtimeRequired: true},
],
databaseActions: [
{
name: 'listSnapshots',
label: 'List DB snapshots',
enabled: true,
status: 'available',
runtimeRequired: true,
},
{
name: 'createSnapshot',
label: 'Create DB snapshot',
enabled: true,
status: 'partial',
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
runtimeRequired: true,
},
],
},
filters: databaseFilters,
columns: databaseColumns,
updateFields: [
{
name: 'masterUserPassword',
label: 'Master Password',
type: 'password',
required: false,
span: true,
description: 'Leave blank to keep the current password. Class, storage, engine, and version are omitted because the current emulator does not support modifying them.',
validation: {minLength: 8, maxLength: 128},
},
{
name: 'enableIamDatabaseAuthentication',
label: 'IAM Database Authentication',
type: 'select',
required: false,
valuePath: 'metadata.iamDatabaseAuthenticationEnabled',
description: 'Leave unchanged to keep current setting.',
options: [
{label: 'True', value: 'true'},
{label: 'False', value: 'false'},
],
},
{
name: 'dbSubnetGroupName',
label: 'DB Subnet Group',
type: 'text',
required: false,
valuePath: 'metadata.subnetGroup.name',
description: 'Leave unchanged or blank to keep current DB subnet group.',
},
{
name: 'vpcSecurityGroupIds',
label: 'VPC Security Group IDs',
type: 'text',
required: false,
valuePath: 'metadata.vpcSecurityGroupIds',
description: 'Comma-separated security group IDs. Leave unchanged or blank to keep current security groups.',
},
{
name: 'optionGroupName',
label: 'Option Group',
type: 'text',
required: false,
valuePath: 'metadata.optionGroupName',
description: 'Leave unchanged or blank to keep current option group.',
},
{
name: 'autoMinorVersionUpgrade',
label: 'Auto Minor Version Upgrade',
type: 'select',
required: false,
valuePath: 'metadata.autoMinorVersionUpgrade',
description: 'Leave unchanged to keep current setting.',
options: [
{label: 'True', value: 'true'},
{label: 'False', value: 'false'},
],
},
],
}
}
export function azureDatabaseSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'database',
displayName: 'Azure Databases',
fields: [
{
name: 'engine',
label: 'Database Engine',
type: 'select',
required: true,
defaultValue: 'azure-sql',
options: [
{label: 'Azure SQL Database', value: 'azure-sql'},
{label: 'Azure Database for PostgreSQL', value: 'postgresql'},
],
},
{
name: 'serverName',
label: 'Server Name',
type: 'text',
required: true,
validation: {
minLength: 1,
maxLength: 63,
pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$',
message: 'Use 1-63 lowercase letters, numbers, or hyphens.',
},
},
{
name: 'location',
label: 'Location',
type: 'text',
required: true,
defaultValue: 'eastus',
},
{
name: 'administratorLogin',
label: 'Administrator Login',
type: 'text',
required: true,
defaultValue: 'sa',
},
{
name: 'administratorLoginPassword',
label: 'Administrator Password',
type: 'password',
required: true,
span: true,
description: 'Required by the local database runtime. The value is not returned by the API.',
validation: {
minLength: 8,
message: 'Use at least 8 characters.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List database servers', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create database server', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete database server', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: databaseFilters,
columns: [
{name: 'name', label: 'Name'},
{name: 'engine', label: 'Engine'},
{name: 'status', label: 'Status'},
{name: 'region', label: 'Region'},
],
}
}
export function gcpDatabaseSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'database',
displayName: 'Cloud SQL',
fields: [
{
name: 'instanceName',
label: 'Instance Name',
type: 'text',
required: true,
description: 'Lowercase letters, numbers, and hyphens; must start with a letter.',
},
{
name: 'databaseVersion',
label: 'Database Version',
type: 'select',
required: false,
// The runtime backs instances with real Postgres containers and
// rejects every other engine, so this is not the full GCP list.
description: 'The local runtime supports PostgreSQL only.',
options: [
{label: 'PostgreSQL 15', value: 'POSTGRES_15'},
{label: 'PostgreSQL 16', value: 'POSTGRES_16'},
],
},
{
name: 'region',
label: 'Region',
type: 'text',
required: false,
description: 'Defaults to us-central1.',
},
{
name: 'tier',
label: 'Machine Tier',
type: 'text',
required: false,
description: 'Defaults to db-f1-micro.',
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: databaseFilters,
columns: cloudSqlColumns,
capabilities: {
resourceActions: [
{name: 'list', label: 'List instances', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect instance', enabled: true, status: 'available', runtimeRequired: false},
],
},
}
}
@@ -0,0 +1,90 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const dynamodbColumns: TableColumnSchema[] = [
{name: 'name', label: 'Table'},
{name: 'status', label: 'Status'},
{name: 'region', label: 'Region'},
{name: 'createdAt', label: 'Created At'},
]
const dynamodbFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
const attributeTypeOptions = [
{label: 'String', value: 'S'},
{label: 'Number', value: 'N'},
{label: 'Binary', value: 'B'},
]
export function awsDynamoDbSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'nosql',
displayName: 'DynamoDB',
fields: [
{
name: 'tableName',
label: 'Table Name',
type: 'text',
required: true,
description: '3-255 characters. Letters, numbers, underscores, dots, and hyphens.',
validation: {
pattern: '^(?:[A-Za-z0-9_.]|-){3,255}$',
minLength: 3,
maxLength: 255,
message: 'Use a valid DynamoDB table name: 3-255 letters, numbers, underscores, dots, or hyphens.',
},
},
{
name: 'partitionKey',
label: 'Partition Key',
type: 'text',
required: true,
group: 'Primary key',
validation: {
minLength: 1,
maxLength: 255,
message: 'Partition key must contain 1-255 characters.',
},
},
{
name: 'partitionKeyType',
label: 'Partition Key Type',
type: 'select',
required: true,
options: attributeTypeOptions,
},
{
name: 'sortKey',
label: 'Sort Key',
type: 'text',
required: false,
description: 'Optional range key.',
validation: {
minLength: 1,
maxLength: 255,
message: 'Sort key must contain 1-255 characters.',
},
},
{
name: 'sortKeyType',
label: 'Sort Key Type',
type: 'select',
required: false,
options: attributeTypeOptions,
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List tables', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create table', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect table', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: dynamodbFilters,
columns: dynamodbColumns,
}
}
+102
View File
@@ -0,0 +1,102 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const eksColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'Status'},
{name: 'version', label: 'Version'},
{name: 'createdAt', label: 'Created At'},
]
/** GKE reports an API endpoint and node pools, which the EKS list does not. */
const gkeColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'version', label: 'Version'},
{name: 'region', label: 'Location'},
{name: 'endpoint', label: 'Endpoint', path: 'metadata.endpoint', format: 'code'},
{name: 'createdAt', label: 'Created At', format: 'datetime'},
]
const eksFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
export function awsEksSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'k8s',
displayName: 'AWS EKS',
fields: [],
actions: ['list', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List clusters', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect cluster', enabled: true, status: 'available', runtimeRequired: false},
],
kubernetesActions: [
{name: 'listNodegroups', label: 'List nodegroups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createNodegroup', label: 'Create nodegroup', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteNodegroup', label: 'Delete nodegroup', enabled: true, status: 'available', runtimeRequired: true},
{name: 'listFargateProfiles', label: 'List Fargate profiles', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createFargateProfile', label: 'Create Fargate profile', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteFargateProfile', label: 'Delete Fargate profile', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: eksFilters,
columns: eksColumns,
}
}
export function azureAksSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'k8s',
displayName: 'Azure AKS',
fields: [],
actions: ['list', 'inspect'],
filters: eksFilters,
columns: eksColumns,
}
}
export function gcpGkeSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'k8s',
displayName: 'Google GKE',
fields: [
{
name: 'clusterName',
label: 'Cluster Name',
type: 'text',
required: true,
description: 'Lowercase letters, numbers, and hyphens; must start with a letter.',
},
{
name: 'initialNodeCount',
label: 'Initial Node Count',
type: 'text',
required: false,
description: 'Defaults to 1. The local runtime backs the cluster with a single k3s container.',
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: eksFilters,
columns: gkeColumns,
capabilities: {
resourceActions: [
{name: 'list', label: 'List clusters', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create cluster', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete cluster', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect cluster', enabled: true, status: 'available', runtimeRequired: false},
],
},
}
}
export function k8sSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'aws') return awsEksSchema()
if (cloud === 'azure') return azureAksSchema()
if (cloud === 'gcp') return gcpGkeSchema()
return null
}
+36
View File
@@ -0,0 +1,36 @@
import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
const emailColumns: TableColumnSchema[] = [
{name: 'name', label: 'Subject'},
{name: 'source', label: 'From', path: 'metadata.source', format: 'code'},
{name: 'to', label: 'To', path: 'metadata.toAddresses', format: 'list'},
{name: 'messageType', label: 'Type', path: 'metadata.messageType', format: 'badge'},
{name: 'createdAt', label: 'Captured At', format: 'datetime'},
]
const emailFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
const emailResourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List captured emails', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect email content', enabled: true, status: 'available', runtimeRequired: false},
]
export function awsSesEmailSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'email',
displayName: 'SES Mailbox',
fields: [],
actions: ['list', 'inspect'],
capabilities: {resourceActions: emailResourceActions},
filters: emailFilters,
columns: emailColumns,
}
}
export function emailSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'aws') return awsSesEmailSchema()
return null
}
+134
View File
@@ -0,0 +1,134 @@
import {describe, expect, test} from 'bun:test'
import {
AccessDeniedError,
type CloudError,
type CloudErrorCode,
type CloudErrorStatus,
ConflictError,
NotFoundError,
NotImplementedByRuntimeError,
NotSupportedError,
RateLimitedError,
RuntimeError,
RuntimeUnavailableError,
ValidationError,
httpStatusToCloudError,
isUnreachableCause,
toHttpError,
} from './errors'
describe('CloudError wire shape', () => {
test('keeps the generic label in message and the raw text in detail', () => {
const body = new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577').toBody()
expect(body.code).toBe('runtime_unavailable')
expect(body.message).toBe('Runtime unavailable')
expect(body.error).toBe(body.message)
expect(body.detail).toBe('Cannot reach Floci-AZ at http://localhost:4577')
})
test('omits detail when it would duplicate the message', () => {
// ValidationError intentionally uses its own message as the label, so a
// field-level complaint reaches the UI verbatim instead of being generified.
const body = new ValidationError('bucketName is required').toBody()
expect(body.message).toBe('bucketName is required')
expect(body.detail).toBeUndefined()
})
test('preserves the cause chain for debugging', () => {
const cause = new Error('socket hang up')
expect(new RuntimeError('wrapped', {cause}).cause).toBe(cause)
})
const statuses: Array<[CloudError, CloudErrorStatus, CloudErrorCode]> = [
[new ValidationError('x'), 400, 'invalid_request'],
[new AccessDeniedError('x'), 403, 'access_denied'],
[new NotFoundError('x'), 404, 'resource_not_found'],
[new ConflictError('x'), 409, 'resource_conflict'],
[new RateLimitedError('x'), 429, 'rate_limited'],
[new NotSupportedError('x'), 501, 'operation_not_supported'],
[new NotImplementedByRuntimeError('x'), 501, 'operation_not_implemented'],
[new RuntimeError('x'), 502, 'runtime_error'],
[new RuntimeUnavailableError('x'), 503, 'runtime_unavailable'],
]
for (const [error, status, code] of statuses) {
test(`${error.name} carries ${status}/${code}`, () => {
expect(error.status).toBe(status)
expect(error.code).toBe(code)
expect(toHttpError(error).status).toBe(status)
})
}
})
describe('httpStatusToCloudError', () => {
const cases: Array<[number, CloudErrorStatus, CloudErrorCode]> = [
[400, 400, 'invalid_request'],
[401, 403, 'access_denied'],
[403, 403, 'access_denied'],
[404, 404, 'resource_not_found'],
[409, 409, 'resource_conflict'],
[429, 429, 'rate_limited'],
[501, 501, 'operation_not_implemented'],
[502, 503, 'runtime_unavailable'],
[503, 503, 'runtime_unavailable'],
[504, 503, 'runtime_unavailable'],
[418, 502, 'runtime_error'],
]
for (const [runtimeStatus, expectedStatus, expectedCode] of cases) {
test(`HTTP ${runtimeStatus} becomes ${expectedStatus}`, () => {
const error = httpStatusToCloudError(runtimeStatus, `HTTP ${runtimeStatus}`)
expect(error.status).toBe(expectedStatus)
expect(error.code).toBe(expectedCode)
})
}
})
describe('isUnreachableCause', () => {
test('detects a nested transport failure code', () => {
const inner = Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'})
expect(isUnreachableCause(new Error('fetch failed', {cause: inner}))).toBe(true)
})
test('detects an aborted or timed-out request by name', () => {
const timeout = new Error('timed out')
timeout.name = 'TimeoutError'
expect(isUnreachableCause(timeout)).toBe(true)
})
test('does not treat an ordinary error as unreachable', () => {
expect(isUnreachableCause(new Error('bucket already exists'))).toBe(false)
expect(isUnreachableCause('not an error')).toBe(false)
})
})
describe('toHttpError', () => {
test('defaults an unknown failure to 502 rather than guessing', () => {
const {status, body} = toHttpError(new Error('something odd happened'))
expect(status).toBe(502)
expect(body.code).toBe('runtime_error')
expect(body.detail).toBe('something odd happened')
})
test('promotes a transport failure to 503 without any message matching', () => {
const inner = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:4566'), {code: 'ECONNREFUSED'})
expect(toHttpError(new Error('fetch failed', {cause: inner})).status).toBe(503)
})
test('lets a vendor mapper win over the generic fallback', () => {
const {status} = toHttpError(new Error('vendor specific'), () => new ConflictError('already exists'))
expect(status).toBe(409)
})
test('a CloudError outranks the vendor mapper', () => {
const {status} = toHttpError(new NotFoundError('missing'), () => new ConflictError('nope'))
expect(status).toBe(404)
})
test('handles a non-Error throw', () => {
expect(toHttpError('boom').status).toBe(502)
})
})
+183
View File
@@ -0,0 +1,183 @@
/**
* Typed errors for the cloud SPI.
*
* Adapters throw these instead of bare `Error`s so `routes/clouds.ts` can map a
* failure to an HTTP status without pattern-matching on message text. The wire
* shape is deliberately unchanged from the hand-rolled mapper it replaces:
* `message` carries a generic label and `detail` carries the raw runtime text.
*/
export type CloudErrorCode =
| 'invalid_request'
| 'access_denied'
| 'resource_not_found'
| 'resource_conflict'
| 'rate_limited'
| 'operation_not_supported'
| 'operation_not_implemented'
| 'runtime_unavailable'
| 'runtime_error'
| 'cosmos_nosql_unavailable'
export type CloudErrorStatus = 400 | 403 | 404 | 409 | 429 | 501 | 502 | 503
export interface CloudErrorBody {
/** Mirrors `message`; kept for clients that read `error`. */
error: string
code: string
message: string
detail?: string
}
export abstract class CloudError extends Error {
abstract readonly status: CloudErrorStatus
abstract readonly code: CloudErrorCode
/**
* Generic, user-facing label. `message` holds the raw runtime text and is
* surfaced as `detail`, so a label change never leaks adapter internals.
*/
protected abstract readonly label: string
constructor(message: string, options?: {cause?: unknown}) {
super(message, options?.cause === undefined ? undefined : {cause: options.cause})
this.name = new.target.name
}
toBody(): CloudErrorBody {
const message = this.label
const detail = this.message
return {
error: message,
code: this.code,
message,
...(detail && detail !== message ? {detail} : {}),
}
}
}
/** 400 — the caller sent something unusable. The raw message is the label. */
export class ValidationError extends CloudError {
readonly status = 400 as const
readonly code = 'invalid_request' as const
protected get label(): string {
return this.message
}
}
export class AccessDeniedError extends CloudError {
readonly status = 403 as const
readonly code = 'access_denied' as const
protected readonly label = 'Access denied by the runtime'
}
export class NotFoundError extends CloudError {
readonly status = 404 as const
readonly code = 'resource_not_found' as const
protected readonly label = 'Resource not found'
}
export class ConflictError extends CloudError {
readonly status = 409 as const
readonly code = 'resource_conflict' as const
protected readonly label = 'Resource already exists or is in use'
}
export class RateLimitedError extends CloudError {
readonly status = 429 as const
readonly code = 'rate_limited' as const
protected readonly label = 'Runtime is throttling requests'
}
/** 501 — this adapter does not implement the operation at all. */
export class NotSupportedError extends CloudError {
readonly status = 501 as const
readonly code = 'operation_not_supported' as const
protected readonly label = 'Operation is not supported by this adapter'
}
/** 501 — the adapter supports it but the local runtime answered NotImplemented. */
export class NotImplementedByRuntimeError extends CloudError {
readonly status = 501 as const
readonly code = 'operation_not_implemented' as const
protected readonly label = 'Operation is not implemented by the selected runtime'
}
export class RuntimeUnavailableError extends CloudError {
readonly status = 503 as const
readonly code = 'runtime_unavailable' as const
protected readonly label = 'Runtime unavailable'
}
export class RuntimeError extends CloudError {
readonly status = 502 as const
readonly code = 'runtime_error' as const
protected readonly label = 'Runtime request failed'
}
/** Transport-level failure codes that mean "nothing is listening over there". */
const UNREACHABLE_CAUSE_CODES = new Set([
'ECONNREFUSED',
'ENOTFOUND',
'EAI_AGAIN',
'ECONNRESET',
'EHOSTUNREACH',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_SOCKET',
])
/**
* Map a runtime HTTP status onto the matching `CloudError`. Shared by the Azure
* and GCP REST clients so both report a 404 or a 501 the same way.
*/
export function httpStatusToCloudError(status: number, message: string, options?: {cause?: unknown}): CloudError {
if (status === 400) return new ValidationError(message, options)
if (status === 401 || status === 403) return new AccessDeniedError(message, options)
if (status === 404) return new NotFoundError(message, options)
if (status === 409) return new ConflictError(message, options)
if (status === 429) return new RateLimitedError(message, options)
if (status === 501) return new NotImplementedByRuntimeError(message, options)
if (status === 502 || status === 503 || status === 504) return new RuntimeUnavailableError(message, options)
return new RuntimeError(message, options)
}
/** True when the failure is a transport error rather than an HTTP response. */
export function isUnreachableCause(err: unknown): boolean {
if (!(err instanceof Error)) return false
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true
let cause: unknown = err
for (let depth = 0; depth < 5 && cause instanceof Error; depth += 1) {
const code = (cause as {code?: unknown}).code
if (typeof code === 'string' && UNREACHABLE_CAUSE_CODES.has(code)) return true
cause = (cause as {cause?: unknown}).cause
}
return false
}
/**
* The single place adapter failures become HTTP responses.
*
* `mapVendorError` lets a provider contribute SDK-specific knowledge (see
* `adapter-aws/awsErrors.ts`) without `cloud-spi` importing any vendor package.
*/
export function toHttpError(
err: unknown,
mapVendorError?: (err: unknown) => CloudError | null,
): {status: CloudErrorStatus; body: CloudErrorBody} {
const mapped = resolveCloudError(err, mapVendorError)
return {status: mapped.status, body: mapped.toBody()}
}
function resolveCloudError(err: unknown, mapVendorError?: (err: unknown) => CloudError | null): CloudError {
if (err instanceof CloudError) return err
const vendor = mapVendorError?.(err)
if (vendor) return vendor
const message = err instanceof Error ? err.message : 'Runtime request failed'
if (isUnreachableCause(err)) return new RuntimeUnavailableError(message, {cause: err})
return new RuntimeError(message, {cause: err})
}
@@ -0,0 +1,38 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const eventBusColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'type', label: 'Resource Type'},
{name: 'region', label: 'Region'},
{name: 'createdAt', label: 'Created At', format: 'datetime'},
]
const eventBusFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
export function awsEventBridgeSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'events',
displayName: 'EventBridge',
fields: [
{
name: 'name',
label: 'Event Bus Name',
type: 'text',
required: true,
description: 'Letters, numbers, periods, hyphens, and underscores.',
validation: {
pattern: '^[A-Za-z0-9._\\-]+$',
maxLength: 256,
message: 'Use letters, numbers, periods, hyphens, or underscores.',
},
},
{name: 'description', label: 'Description', type: 'text', required: false},
],
actions: ['list', 'create', 'delete', 'inspect'],
filters: eventBusFilters,
columns: eventBusColumns,
}
}
+57
View File
@@ -0,0 +1,57 @@
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
export const IAM_USER_NAME_PATTERN = '^[A-Za-z0-9_+=,.@-]{1,64}$'
export const IAM_USER_NAME_MESSAGE = 'Use a valid IAM user name: 1-64 letters, numbers, and +=,.@_- characters.'
export const IAM_PATH_PATTERN = '^/(?:[!-~]+/)?$'
export const IAM_PATH_MAX_LENGTH = 512
export const IAM_PATH_MESSAGE = 'Use a valid IAM path: begin and end with / and use only printable ASCII characters.'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect user', enabled: true, status: 'available', runtimeRequired: false},
]
export function awsIamSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'identity',
displayName: 'AWS IAM users',
fields: [
{
name: 'userName',
label: 'User Name',
type: 'text',
required: true,
description: '1-64 letters, numbers, and +=,.@_- characters.',
validation: {
pattern: IAM_USER_NAME_PATTERN,
minLength: 1,
maxLength: 64,
message: IAM_USER_NAME_MESSAGE,
},
},
{
name: 'path',
label: 'Path',
type: 'text',
required: false,
description: 'Optional IAM path, beginning and ending with /. Defaults to /.',
validation: {
pattern: IAM_PATH_PATTERN,
maxLength: IAM_PATH_MAX_LENGTH,
message: IAM_PATH_MESSAGE,
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'User Name'},
{name: 'type', label: 'Type'},
{name: 'createdAt', label: 'Created At'},
],
}
}
+105
View File
@@ -0,0 +1,105 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/**
* A KMS key has no name — only an opaque uuid and an optional description — so
* the description carries the identity in the table and in search.
*/
const kmsColumns: TableColumnSchema[] = [
{name: 'name', label: 'Key ID', format: 'code'},
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'keyUsage', label: 'Usage', path: 'metadata.keyUsage'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const kmsFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
/** Offered on create. Kept in sync with the adapter's validation. */
export const KMS_KEY_USAGES = ['ENCRYPT_DECRYPT', 'SIGN_VERIFY', 'GENERATE_VERIFY_MAC'] as const
export const KMS_KEY_SPECS = [
'SYMMETRIC_DEFAULT',
'RSA_2048',
'RSA_4096',
'ECC_NIST_P256',
'HMAC_256',
] as const
/**
* Which specs each usage can actually be created with, and the spec to use when
* the caller picks a usage but no spec.
*
* KMS rejects a mismatched pair — a SIGN_VERIFY key cannot be SYMMETRIC_DEFAULT,
* and only HMAC specs can generate MACs. The local runtime is more permissive and
* returns 200 for pairs real KMS refuses, so this table, not the runtime, is the
* authority. The first entry of each list is the default.
*/
export const KMS_SPECS_BY_USAGE = {
ENCRYPT_DECRYPT: ['SYMMETRIC_DEFAULT', 'RSA_2048', 'RSA_4096'],
SIGN_VERIFY: ['RSA_2048', 'RSA_4096', 'ECC_NIST_P256'],
GENERATE_VERIFY_MAC: ['HMAC_256'],
} as const satisfies Record<(typeof KMS_KEY_USAGES)[number], readonly (typeof KMS_KEY_SPECS)[number][]>
/** KMS caps a key description at 8192 characters. */
export const KMS_DESCRIPTION_MAX_LENGTH = 8192
export function awsKmsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'kms',
displayName: 'AWS KMS',
fields: [
{
name: 'description',
label: 'Description',
type: 'text',
required: false,
description: 'How this key is used. A key has no name, so this is how you will recognise it.',
span: true,
validation: {
maxLength: KMS_DESCRIPTION_MAX_LENGTH,
message: `Keep the description under ${KMS_DESCRIPTION_MAX_LENGTH} characters.`,
},
},
{
name: 'keyUsage',
label: 'Key Usage',
type: 'select',
required: false,
options: KMS_KEY_USAGES.map((value) => ({label: value, value})),
},
{
name: 'keySpec',
label: 'Key Spec',
type: 'select',
required: false,
description:
'Must match the key usage: symmetric or RSA to encrypt, RSA or ECC to sign, HMAC to generate MACs. Left unset, a valid spec is chosen for the usage.',
options: KMS_KEY_SPECS.map((value) => ({label: value, value})),
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List keys', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create key', enabled: true, status: 'available', runtimeRequired: true},
{
name: 'delete',
label: 'Schedule key deletion',
enabled: true,
status: 'available',
runtimeRequired: true,
},
{name: 'inspect', label: 'Inspect key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'encrypt', label: 'Encrypt plaintext', enabled: true, status: 'available', runtimeRequired: true},
{name: 'decrypt', label: 'Decrypt ciphertext', enabled: true, status: 'available', runtimeRequired: true},
{name: 'updateTags', label: 'Edit tags', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: kmsFilters,
columns: kmsColumns,
}
}
export function kmsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsKmsSchema() : null
}
@@ -0,0 +1,12 @@
import {describe, expect, test} from 'bun:test'
import {lambdaStarterCode} from './lambdaStarterCode'
describe('lambdaStarterCode', () => {
test('returns a JavaScript handler for Node.js runtimes', () => {
expect(lambdaStarterCode('nodejs22.x')).toContain('exports.handler')
})
test('returns a Python handler for Python runtimes', () => {
expect(lambdaStarterCode('python3.12')).toContain('def handler(event, context)')
})
})
@@ -0,0 +1,33 @@
const NODEJS_STARTER_CODE = `exports.handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify({
message: 'Hello from Lambda!',
event,
}),
};
};`
const PYTHON_STARTER_CODE = `import json
def handler(event, context):
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Hello from Lambda!',
'event': event,
}),
}`
export const lambdaStarterCodeByRuntime: Record<string, string> = {
'nodejs22.x': NODEJS_STARTER_CODE,
'nodejs20.x': NODEJS_STARTER_CODE,
'nodejs18.x': NODEJS_STARTER_CODE,
'python3.12': PYTHON_STARTER_CODE,
'python3.11': PYTHON_STARTER_CODE,
}
export function lambdaStarterCode(runtime: string): string {
return lambdaStarterCodeByRuntime[runtime] ?? NODEJS_STARTER_CODE
}
@@ -0,0 +1,86 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const loadBalancerColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'lbType', label: 'Type', path: 'metadata.lbType'},
{name: 'scheme', label: 'Scheme', path: 'metadata.scheme'},
{name: 'dnsName', label: 'DNS Name', path: 'metadata.dnsName', format: 'code', emptyText: '—'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const loadBalancerFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export const ELB_TYPES = ['application', 'network'] as const
export const ELB_SCHEMES = ['internet-facing', 'internal'] as const
/** ELBv2 names: up to 32 chars, alphanumerics and hyphens, no leading/trailing hyphen. */
export const ELB_NAME_PATTERN = '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$'
export function awsLoadBalancingSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'loadbalancing',
displayName: 'AWS Elastic Load Balancing',
fields: [
{
name: 'name',
label: 'Load Balancer Name',
type: 'text',
required: true,
group: 'Required',
validation: {
pattern: ELB_NAME_PATTERN,
minLength: 1,
maxLength: 32,
message: 'Up to 32 characters: letters, digits and hyphens, not starting or ending with a hyphen.',
},
},
{
name: 'subnets',
label: 'Subnet IDs',
type: 'text',
required: true,
span: true,
group: 'Required',
// Follows the compute schema, which takes security group ids the
// same way rather than pushing create into a bespoke panel.
description:
'Two or more subnet ids, comma separated — e.g. subnet-aaa, subnet-bbb. ELB requires them to be in different availability zones.',
},
{
name: 'type',
label: 'Type',
type: 'select',
required: false,
description: 'Defaults to application.',
options: ELB_TYPES.map((value) => ({label: value, value})),
},
{
name: 'scheme',
label: 'Scheme',
type: 'select',
required: false,
description: 'Defaults to internet-facing.',
options: ELB_SCHEMES.map((value) => ({label: value, value})),
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List load balancers', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true},
{
name: 'create',
label: 'Create load balancer',
enabled: true,
status: 'available',
runtimeRequired: true,
},
{name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: loadBalancerFilters,
columns: loadBalancerColumns,
}
}
+62
View File
@@ -0,0 +1,62 @@
import type {ServiceSchema} from './types'
export function awsLogsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'logs',
displayName: 'CloudWatch Logs',
fields: [
{
name: 'name',
label: 'Log group name',
type: 'text',
required: true,
validation: {
minLength: 1,
maxLength: 512,
pattern: '^[A-Za-z0-9_./#-]+$',
message: 'Use letters, numbers, underscore, dot, slash, hash, or dash.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List log groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create log group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete log group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
],
collectionActions: [
{name: 'list', label: 'List log streams', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create log stream', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete log stream', enabled: true, status: 'available', runtimeRequired: true},
],
// Read-only leaf, and that is the provider's shape rather than a gap:
// AWS has no DeleteLogEvent, and writing events from a resource
// browser is not something a console should offer.
itemActions: [
{name: 'list', label: 'Read log events', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: [
{
name: 'search',
label: 'Name starts with',
type: 'text',
required: false,
description: 'CloudWatch filters log groups by prefix, not by substring.',
},
],
columns: [
{name: 'name', label: 'Log group'},
// No 'Stored' column here, deliberately: the runtime's DescribeLogGroups
// always reports storedBytes as 0 at the group level (only the stream
// level is accurate), so a table cell showing "0 B" would tell the user
// a populated group is empty. The field still travels in
// metadata.storedBytes for anyone reading the resource directly.
{name: 'retentionInDays', label: 'Retention', path: 'metadata.retentionInDays', emptyText: 'Never expires'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
],
}
}
@@ -0,0 +1,91 @@
import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
/**
* One category for queue- and topic-shaped services.
*
* SQS queues and Pub/Sub topics differ in delivery semantics but present the
* same way in a console — a named endpoint with a depth and a few settings — so
* splitting them into separate categories would double the nav for no gain.
*/
const messagingColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'type', label: 'Type'},
{name: 'messages', label: 'Messages', path: 'metadata.approximateMessages'},
{name: 'createdAt', label: 'Created At', format: 'datetime'},
]
const pubSubColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'type', label: 'Type'},
{name: 'path', label: 'Resource Path', path: 'metadata.resourcePath', format: 'code'},
]
const messagingFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
function crudCapabilities(noun: string): CapabilitySchema<ResourceActionName>[] {
return [
{name: 'list', label: `List ${noun}s`, enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: `Create ${noun}`, enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: `Delete ${noun}`, enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: `Inspect ${noun}`, enabled: true, status: 'available', runtimeRequired: false},
]
}
export function awsMessagingSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'messaging',
displayName: 'AWS SQS',
fields: [
{
name: 'queueName',
label: 'Queue Name',
type: 'text',
required: true,
description: 'Up to 80 alphanumeric characters, hyphens, or underscores.',
},
{
name: 'visibilityTimeout',
label: 'Visibility Timeout',
type: 'text',
required: false,
description: 'Seconds a received message stays hidden. Defaults to 30.',
},
{
name: 'messageRetentionPeriod',
label: 'Retention Period',
type: 'text',
required: false,
description: 'Seconds a message is kept. Defaults to 345600 (4 days).',
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: messagingFilters,
columns: messagingColumns,
capabilities: {resourceActions: crudCapabilities('queue')},
}
}
export function gcpMessagingSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'messaging',
displayName: 'Cloud Pub/Sub',
fields: [
{
name: 'topicName',
label: 'Topic Name',
type: 'text',
required: true,
description: 'Letters, numbers, hyphens, underscores, periods, tildes, plus, or percent signs.',
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: messagingFilters,
columns: pubSubColumns,
capabilities: {resourceActions: crudCapabilities('topic')},
}
}
@@ -0,0 +1,49 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const networkingColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'version', label: 'CIDR', path: 'metadata.cidrBlock', format: 'code'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'type', label: 'Type'},
]
const networkingFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
export function awsNetworkingSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'networking',
displayName: 'Networking',
fields: [],
actions: ['list'],
filters: networkingFilters,
columns: networkingColumns,
capabilities: {
resourceActions: [
{name: 'list', label: 'VPCs', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true},
// The generic form cannot express the dependent selectors these
// need, so both live in the Networking panel. Advertising them as
// available here produced a 502 telling the user to go elsewhere.
{
name: 'create',
label: 'Create resources',
enabled: false,
status: 'partial',
reason: 'Use the Networking panel — VPC and subnet creation need dependent selectors.',
runtimeRequired: true,
},
{
name: 'delete',
label: 'Delete resources',
enabled: false,
status: 'partial',
reason: 'Use the Networking panel — deletion must resolve dependent networking resources first.',
runtimeRequired: true,
},
],
},
}
}
+45
View File
@@ -0,0 +1,45 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const noSqlFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
const noSqlColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'engine', label: 'Engine'},
{name: 'status', label: 'Status'},
{name: 'createdAt', label: 'Created At'},
]
export function azureNoSqlSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'nosql',
displayName: 'Azure Cosmos DB NoSQL',
fields: [
{
name: 'databaseName',
label: 'Database Name',
type: 'text',
required: true,
validation: {
minLength: 1,
maxLength: 255,
pattern: '^[A-Za-z0-9._-]+$',
message: 'Use letters, numbers, dot, underscore, or dash.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List Cosmos databases', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create Cosmos database', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete Cosmos database', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: noSqlFilters,
columns: noSqlColumns,
}
}
@@ -0,0 +1,86 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/**
* No value column, by design. A parameter value never travels with the resource
* (see AwsParameterStoreAdapter), so offering the column would render an empty
* cell for every row.
*/
const parameterColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name', format: 'code'},
{name: 'parameterType', label: 'Type', path: 'metadata.parameterType', format: 'badge'},
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
{name: 'version', label: 'Version', path: 'metadata.version'},
{name: 'createdAt', label: 'Last Modified', format: 'datetime'},
]
const parameterFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export const PARAMETER_TYPES = ['String', 'StringList', 'SecureString'] as const
/** SSM caps a Standard-tier parameter name at 1011 characters. */
export const PARAMETER_NAME_MAX_LENGTH = 1011
export function awsParametersSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'parameters',
displayName: 'SSM Parameter Store',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
description: 'Hierarchical names are conventional, e.g. /app/prod/region.',
validation: {
minLength: 1,
maxLength: PARAMETER_NAME_MAX_LENGTH,
message: 'Use a name of up to 1011 characters, optionally with / separators.',
},
},
{
name: 'type',
label: 'Type',
type: 'select',
required: false,
description: 'SecureString is stored encrypted. Its value is never returned to the console.',
options: PARAMETER_TYPES.map((value) => ({label: value, value})),
},
{
name: 'value',
label: 'Value',
type: 'text',
required: true,
span: true,
description: 'Accepted here on create, but never read back.',
},
{name: 'description', label: 'Description', type: 'text', required: false, span: true},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List parameters', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create parameter', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete parameter', enabled: true, status: 'available', runtimeRequired: true},
{
name: 'inspect',
label: 'Inspect parameter',
enabled: true,
// Metadata only. The value is deliberately withheld, so this is
// not a full inspect and the UI should say so rather than
// render a blank field and look broken.
status: 'partial',
reason: 'Parameter values are never returned to the console, so the inspector shows metadata only.',
runtimeRequired: true,
},
{name: 'updateTags', label: 'Edit tags', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: parameterFilters,
columns: parameterColumns,
}
}
export function parametersSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsParametersSchema() : null
}
@@ -0,0 +1,96 @@
import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
const schedulerColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'schedule', label: 'Schedule', path: 'metadata.schedule'},
{name: 'timeZone', label: 'Time Zone', path: 'metadata.timeZone'},
{name: 'target', label: 'Target URI', path: 'metadata.target'},
{name: 'status', label: 'State'},
{name: 'region', label: 'Region'},
{name: 'createdAt', label: 'Created / Updated At'},
]
const schedulerFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
const schedulerResourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List jobs', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create job', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete job', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect job', enabled: true, status: 'available', runtimeRequired: false},
]
export function gcpSchedulerSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'scheduler',
displayName: 'Cloud Scheduler',
fields: [
{
name: 'jobName',
label: 'Job Name',
type: 'text',
required: true,
description: 'Unique identifier for the job.',
validation: {
pattern: '^[a-zA-Z0-9_-]{1,500}$',
minLength: 1,
maxLength: 500,
message: 'Use a valid job name: 1-500 alphanumeric characters, underscores, or hyphens.',
},
},
{
name: 'schedule',
label: 'Schedule (Cron)',
type: 'text',
required: true,
description: 'Cron schedule syntax (e.g. * * * * * or 0 9 * * 1).',
},
{
name: 'timeZone',
label: 'Time Zone',
type: 'text',
required: false,
description: 'Timezone (e.g. UTC, America/New_York). Defaults to UTC.',
},
{
name: 'targetUri',
label: 'Target HTTP URI',
type: 'text',
required: true,
description: 'Target URL invoked when the job fires.',
},
{
name: 'httpMethod',
label: 'HTTP Method',
type: 'select',
required: false,
options: [
{label: 'POST', value: 'POST'},
{label: 'GET', value: 'GET'},
{label: 'PUT', value: 'PUT'},
{label: 'DELETE', value: 'DELETE'},
],
},
{
name: 'description',
label: 'Description',
type: 'text',
required: false,
description: 'Optional description of the job.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: schedulerResourceActions,
},
filters: schedulerFilters,
columns: schedulerColumns,
}
}
export function schedulerSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'gcp') return gcpSchedulerSchema()
return null
}
+138
View File
@@ -0,0 +1,138 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
// The hyphen is escaped so the pattern also compiles under the `v` flag used for
// HTML pattern validation in the browser.
export const SECRET_NAME_PATTERN = '^[0-9A-Za-z\\-]{1,127}$'
export const SECRET_NAME_MESSAGE = 'Use a valid Key Vault secret name: 1-127 letters, numbers, or hyphens.'
const secretsFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
// The list endpoint returns base secret identifiers without a version, so a Version
// column would be blank for every row. Versions are surfaced on inspect instead.
const secretsColumns: TableColumnSchema[] = [
{name: 'name', label: 'Secret Name'},
{name: 'status', label: 'Status'},
{name: 'createdAt', label: 'Created At'},
]
export function azureSecretsSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'secrets',
displayName: 'Key Vault',
fields: [
{
name: 'secretName',
label: 'Secret Name',
type: 'text',
required: true,
description: 'Unique Key Vault secret name.',
validation: {
minLength: 1,
maxLength: 127,
pattern: SECRET_NAME_PATTERN,
message: SECRET_NAME_MESSAGE,
},
},
{
name: 'secretValue',
label: 'Secret Value',
type: 'password',
required: true,
description: 'Value stored in the secret.',
span: true,
},
{
name: 'contentType',
label: 'Content Type',
type: 'text',
required: false,
description: 'Optional content type, for example application/json.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: secretsFilters,
columns: secretsColumns,
}
}
const awsSecretsColumns: TableColumnSchema[] = [
{name: 'name', label: 'Secret Name'},
{name: 'status', label: 'Status'},
{name: 'createdAt', label: 'Created At', format: 'datetime'},
]
const gcpSecretsColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'createdAt', label: 'Created At', format: 'datetime'},
{name: 'replication', label: 'Replication', path: 'metadata.replication'},
]
export function awsSecretsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'secrets',
displayName: 'AWS Secrets Manager',
fields: [
{name: 'secretName', label: 'Secret Name', type: 'text', required: true},
{name: 'description', label: 'Description', type: 'text', required: false},
{
name: 'secretValue',
label: 'Secret Value',
type: 'password',
required: false,
description: 'Optional initial value. Stored by the runtime and never read back into the console.',
span: true,
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: secretsFilters,
columns: awsSecretsColumns,
capabilities: {
resourceActions: [
{name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
],
},
}
}
export function gcpSecretsSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'secrets',
displayName: 'Secret Manager',
fields: [
{
name: 'secretName',
label: 'Secret Name',
type: 'text',
required: true,
description: 'Letters, numbers, hyphens, and underscores.',
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: secretsFilters,
columns: gcpSecretsColumns,
capabilities: {
resourceActions: [
{name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true},
],
},
}
}
@@ -0,0 +1,265 @@
import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
import {lambdaStarterCodeByRuntime} from './lambdaStarterCode'
const serverlessColumns: TableColumnSchema[] = [
{name: 'name', label: 'Function Name'},
{name: 'type', label: 'Type'},
{name: 'cloud', label: 'Cloud'},
{name: 'region', label: 'Region'},
{name: 'runtime', label: 'Runtime', path: 'metadata.runtime'},
{name: 'status', label: 'Status'},
{name: 'updatedAt', label: 'Last Updated', path: 'metadata.lastModified', format: 'datetime'},
]
/** Invoke is the verb that distinguishes serverless from every other category. */
function serverlessResourceActions(
invoke: CapabilitySchema<ResourceActionName>,
): CapabilitySchema<ResourceActionName>[] {
return [
{name: 'list', label: 'List functions', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create function', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete function', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect function', enabled: true, status: 'available', runtimeRequired: false},
invoke,
]
}
const serverlessFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
{name: 'runtime', label: 'Runtime', type: 'text', required: false},
]
export function awsServerlessSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'serverless',
displayName: 'AWS Lambda',
fields: [
{
name: 'functionName',
label: 'Function Name',
type: 'text',
required: true,
description: 'Unique Lambda function name.',
},
{
name: 'runtime',
label: 'Runtime',
type: 'select',
required: true,
options: [
{label: 'Node.js 22.x', value: 'nodejs22.x'},
{label: 'Node.js 20.x', value: 'nodejs20.x'},
{label: 'Node.js 18.x', value: 'nodejs18.x'},
{label: 'Python 3.12', value: 'python3.12'},
{label: 'Python 3.11', value: 'python3.11'},
],
defaultValue: 'nodejs22.x',
},
{
name: 'architecture',
label: 'Architecture',
type: 'select',
required: true,
options: [
{label: 'x86_64', value: 'x86_64'},
{label: 'arm64', value: 'arm64'},
],
defaultValue: 'x86_64',
description: 'Select the architecture used to build native dependencies.',
},
{
name: 'handler',
label: 'Handler',
type: 'text',
required: true,
description: 'Example: index.handler',
defaultValue: 'index.handler',
},
{
name: 'role',
label: 'Execution Role ARN',
type: 'text',
required: true,
description: 'IAM role ARN used by the Lambda function.',
},
{
name: 'memorySize',
label: 'Memory Size',
type: 'text',
required: false,
description: 'Memory in MB. Default: 128.',
},
{
name: 'timeout',
label: 'Timeout',
type: 'text',
required: false,
description: 'Timeout in seconds. Default: 3.',
},
{
name: 'description',
label: 'Description',
type: 'text',
required: false,
},
{
name: 'code',
label: 'Hello World Source',
type: 'textarea',
required: false,
description: 'Edit the default handler, or upload an existing ZIP package below.',
span: true,
defaultValueWhen: {field: 'runtime', values: lambdaStarterCodeByRuntime},
},
{
name: 'zipFile',
label: 'Upload ZIP Deployment Package',
type: 'file',
required: false,
accept: '.zip,application/zip',
description: 'Uses this ZIP instead of the Hello World source.',
span: true,
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: serverlessFilters,
columns: serverlessColumns,
capabilities: {
resourceActions: serverlessResourceActions({
name: 'invoke', label: 'Invoke function', enabled: true, status: 'available', runtimeRequired: true,
}),
},
}
}
export function azureServerlessSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'serverless',
displayName: 'Azure Functions',
fields: [
{
name: 'functionName',
label: 'Function Name',
type: 'text',
required: true,
description: 'Unique Azure Function name.',
},
{
name: 'runtime',
label: 'Runtime',
type: 'select',
required: false,
options: [
{label: 'Node.js', value: 'node'},
{label: 'Python', value: 'python'},
{label: '.NET', value: 'dotnet'},
{label: 'Java', value: 'java'},
],
},
{
name: 'handler',
label: 'Handler',
type: 'text',
required: false,
description: 'Optional function entry point or handler.',
},
{
name: 'functionAppName',
label: 'Function App Name',
type: 'text',
required: false,
description: 'Optional parent Function App name.',
},
{
name: 'location',
label: 'Location',
type: 'text',
required: false,
description: 'Azure region, for example eastus.',
},
{
name: 'code',
label: 'Inline Code',
type: 'text',
required: false,
description: 'Optional starter code. Package deployment can follow in a later PR.',
span: true,
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: serverlessFilters,
columns: serverlessColumns,
capabilities: {
resourceActions: serverlessResourceActions({
name: 'invoke', label: 'Invoke function', enabled: true, status: 'available', runtimeRequired: true,
}),
},
}
}
export function gcpServerlessSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'serverless',
displayName: 'Cloud Functions',
fields: [
{
name: 'functionName',
label: 'Function Name',
type: 'text',
required: true,
description: 'Unique Cloud Function name within the project and region.',
},
{
name: 'runtime',
label: 'Runtime',
type: 'select',
required: true,
options: [
{label: 'Node.js 20', value: 'nodejs20'},
{label: 'Node.js 18', value: 'nodejs18'},
{label: 'Python 3.12', value: 'python312'},
{label: 'Python 3.11', value: 'python311'},
{label: 'Go 1.22', value: 'go122'},
],
},
{
name: 'entryPoint',
label: 'Entry Point',
type: 'text',
required: true,
description: 'Name of the exported function to execute. Example: helloWorld',
},
{
name: 'code',
label: 'Inline Code',
type: 'text',
required: false,
description: 'Optional inline starter code. Source archive upload will come in a later PR.',
span: true,
},
],
actions: ['list', 'create', 'inspect', 'delete'],
filters: serverlessFilters,
columns: serverlessColumns,
capabilities: {
resourceActions: serverlessResourceActions({
name: 'invoke',
label: 'Invoke function',
enabled: false,
status: 'coming_soon',
reason: 'The Cloud Functions :call endpoint is not wired through the adapter yet.',
runtimeRequired: true,
}),
},
}
}
export function serverlessSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'aws') return awsServerlessSchema()
if (cloud === 'azure') return azureServerlessSchema()
if (cloud === 'gcp') return gcpServerlessSchema()
return null
}
@@ -0,0 +1,97 @@
import {describe, expect, test} from 'bun:test'
import {
SERVICE_CATALOG,
SERVICE_CATALOG_ENTRIES,
SERVICE_GROUP_ORDER,
SERVICE_TYPES,
catalogEntry,
displayNameFor,
isServiceType,
routeFor,
} from './serviceCatalog'
describe('SERVICE_CATALOG', () => {
test('every entry carries the metadata the nav needs', () => {
for (const entry of SERVICE_CATALOG_ENTRIES) {
expect(entry.displayName.length).toBeGreaterThan(0)
expect(entry.iconKey.length).toBeGreaterThan(0)
expect(SERVICE_GROUP_ORDER).toContain(entry.group)
expect(Number.isFinite(entry.order)).toBe(true)
expect(routeFor(entry, 'aws').length).toBeGreaterThan(0)
}
})
test('exposes one entry per catalog key', () => {
expect(SERVICE_CATALOG_ENTRIES).toHaveLength(Object.keys(SERVICE_CATALOG).length)
expect(new Set(SERVICE_TYPES).size).toBe(SERVICE_TYPES.length)
})
test('registers the iac category under the Provisioning group', () => {
expect(SERVICE_GROUP_ORDER).toContain('Provisioning')
const iac = catalogEntry('iac')!
expect(iac.group).toBe('Provisioning')
expect(isServiceType('iac')).toBe(true)
expect(displayNameFor(iac, 'aws')).toBe('CloudFormation')
})
test('orders entries by group then in-group order', () => {
const positions = SERVICE_CATALOG_ENTRIES.map((entry) => [
SERVICE_GROUP_ORDER.indexOf(entry.group),
entry.order,
])
for (let i = 1; i < positions.length; i += 1) {
const [prevGroup, prevOrder] = positions[i - 1]!
const [group, order] = positions[i]!
expect(prevGroup < group || (prevGroup === group && prevOrder <= order)).toBe(true)
}
})
test('routes default to the slug', () => {
expect(routeFor(catalogEntry('storage')!, 'aws')).toBe('storage')
expect(routeFor(catalogEntry('storage')!, 'gcp')).toBe('storage')
})
test('a per-cloud override routes one provider to its bespoke page', () => {
// AWS Secrets Manager keeps its dedicated page until value reveal exists
// as a row action; GCP goes through the generic explorer today.
const secrets = catalogEntry('secrets')!
expect(routeFor(secrets, 'aws')).toBe('/secretsmanager')
expect(routeFor(secrets, 'gcp')).toBe('secrets')
})
test('resolves per-cloud routes so one category can span a legacy page and the explorer', () => {
const secrets = catalogEntry('secrets')!
expect(routeFor(secrets, 'aws')).toBe('/secretsmanager')
expect(routeFor(secrets, 'azure')).toBe('secrets')
// A category with no per-cloud override is unaffected by the cloud argument.
expect(routeFor(catalogEntry('storage')!, 'azure')).toBe('storage')
})
test('resolves per-cloud display names', () => {
const k8s = catalogEntry('k8s')!
expect(displayNameFor(k8s, 'aws')).toBe('EKS')
expect(displayNameFor(k8s, 'azure')).toBe('AKS')
expect(displayNameFor(k8s, 'gcp')).toBe('GKE')
// No override -> the shared display name.
expect(displayNameFor(catalogEntry('storage')!, 'gcp')).toBe('Storage')
})
})
describe('isServiceType', () => {
test('accepts every catalog key', () => {
for (const service of SERVICE_TYPES) {
expect(isServiceType(service)).toBe(true)
}
})
test('rejects unknown slugs so routes 404 instead of failing later', () => {
for (const slug of ['queue', 'stroage', '', 'constructor', '__proto__', 'toString']) {
expect(isServiceType(slug)).toBe(false)
}
})
test('catalogEntry returns undefined for an unknown slug', () => {
expect(catalogEntry('queue')).toBeUndefined()
})
})
@@ -0,0 +1,221 @@
import type {CloudAvailability, CloudProvider} from './types'
/**
* The single source of truth for which services the console knows about.
*
* Availability is NOT declared here — it is derived per cloud from whether an
* adapter is registered (see `CloudProxyService.services`). This file only
* carries the presentation metadata the nav needs, which is why adding a
* service is one row here plus one adapter, with no frontend edit.
*
* `CloudServiceType` is derived from these keys, so a new row is also a new
* route-addressable service type. Keeping the union closed means an unknown
* slug is a 404 instead of a service that silently 501s later.
*/
/** Sidebar grouping. A flat list stops being usable past ~12 services. */
export type ServiceGroup =
| 'Compute'
| 'Storage'
| 'Databases'
| 'Networking'
| 'Integration'
| 'Provisioning'
| 'Security'
| 'Observability'
export const SERVICE_GROUP_ORDER: ServiceGroup[] = [
'Compute',
'Storage',
'Databases',
'Networking',
'Integration',
'Provisioning',
'Security',
'Observability',
]
export interface ServiceCatalogMetadata {
displayName: string
/** Per-cloud display override, e.g. 'EKS' vs 'AKS' vs 'GKE'. */
displayNameByCloud?: Partial<Record<CloudProvider, string>>
/** Resolved to a component client-side; an unknown key degrades to a default. */
iconKey: string
group: ServiceGroup
/** Sort order within the group. */
order: number
/** Defaults to the catalog key. A leading '/' marks a page outside Cloud Explorer. */
route?: string
/**
* Per-cloud route override. Needed when one category is served by a legacy
* standalone page on one cloud and by Cloud Explorer on another — AWS secrets
* still live at /secretsmanager while Azure Key Vault is a normal explorer route.
*/
routeByCloud?: Partial<Record<CloudProvider, string>>
/**
* Escape hatch for a service whose UI predates the SPI, so availability
* cannot come from the registry. Every entry here is migration debt — delete
* the field once a real adapter exists.
*/
legacyAvailability?: Partial<Record<CloudProvider, CloudAvailability>>
}
export const SERVICE_CATALOG = {
compute: {displayName: 'Compute', iconKey: 'compute', group: 'Compute', order: 10},
k8s: {
displayName: 'k8s Engine',
displayNameByCloud: {aws: 'EKS', azure: 'AKS', gcp: 'GKE'},
iconKey: 'k8s',
group: 'Compute',
order: 20,
},
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
containers: {
displayName: 'Containers',
displayNameByCloud: {gcp: 'Cloud Run'},
iconKey: 'containers',
group: 'Compute',
order: 40,
},
storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10},
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
nosql: {
displayName: 'NoSQL',
// Both labels are declared even though each arrives with its own adapter,
// so this row reads the same whichever of the two lands first. A label for
// a cloud with no adapter is inert: availability comes from the registry.
displayNameByCloud: {aws: 'DynamoDB', azure: 'Cosmos DB NoSQL'},
iconKey: 'nosql',
group: 'Databases',
order: 20,
},
networking: {displayName: 'Networking', iconKey: 'networking', group: 'Networking', order: 10},
workflows: {
displayName: 'Workflows',
displayNameByCloud: {aws: 'Step Functions'},
iconKey: 'workflows',
group: 'Integration',
order: 30,
},
loadbalancing: {
displayName: 'Load Balancing',
displayNameByCloud: {aws: 'ELB'},
iconKey: 'loadbalancing',
group: 'Networking',
order: 20,
},
messaging: {
displayName: 'Messaging',
displayNameByCloud: {aws: 'SQS', gcp: 'Pub/Sub'},
iconKey: 'messaging',
group: 'Integration',
order: 10,
},
events: {
displayName: 'Events',
displayNameByCloud: {aws: 'EventBridge'},
iconKey: 'events',
group: 'Integration',
order: 15,
},
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
email: {
displayName: 'Email',
displayNameByCloud: {aws: 'SES Mailbox'},
iconKey: 'email',
group: 'Integration',
order: 20,
},
kms: {
displayName: 'Key Management',
displayNameByCloud: {aws: 'KMS'},
iconKey: 'kms',
group: 'Security',
order: 20,
},
parameters: {
displayName: 'Parameter Store',
iconKey: 'parameters',
group: 'Security',
order: 30,
},
secrets: {
displayName: 'Secrets Manager',
displayNameByCloud: {azure: 'Key Vault', gcp: 'Secret Manager'},
iconKey: 'secrets',
group: 'Security',
order: 10,
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
routeByCloud: {aws: '/secretsmanager'},
},
iac: {
displayName: 'Infrastructure as Code',
displayNameByCloud: {aws: 'CloudFormation'},
iconKey: 'iac',
group: 'Provisioning',
order: 10,
},
configuration: {
displayName: 'Configuration',
displayNameByCloud: {aws: 'AppConfig'},
iconKey: 'configuration',
group: 'Provisioning',
order: 20,
},
scheduler: {
displayName: 'Cloud Scheduler',
displayNameByCloud: {gcp: 'Cloud Scheduler'},
iconKey: 'scheduler',
group: 'Integration',
order: 20,
},
logs: {
displayName: 'Logs',
displayNameByCloud: {aws: 'CloudWatch Logs'},
iconKey: 'logs',
group: 'Observability',
order: 10,
},
} as const satisfies Record<string, ServiceCatalogMetadata>
export type CloudServiceType = keyof typeof SERVICE_CATALOG
export interface ServiceCatalogEntry extends ServiceCatalogMetadata {
service: CloudServiceType
}
/** Every known service, sorted by group then in-group order. */
export const SERVICE_CATALOG_ENTRIES: ServiceCatalogEntry[] = (
Object.keys(SERVICE_CATALOG) as CloudServiceType[]
)
.map((service) => ({service, ...SERVICE_CATALOG[service]}))
.sort(compareCatalogEntries)
export const SERVICE_TYPES: CloudServiceType[] = SERVICE_CATALOG_ENTRIES.map((entry) => entry.service)
export function catalogEntry(service: string): ServiceCatalogEntry | undefined {
return isServiceType(service) ? {service, ...SERVICE_CATALOG[service]} : undefined
}
/**
* Route guard. A slug missing from the catalog is a 404 rather than a service
* that renders and then fails on every call.
*/
export function isServiceType(value: string): value is CloudServiceType {
return Object.hasOwn(SERVICE_CATALOG, value)
}
export function displayNameFor(entry: ServiceCatalogEntry, cloud: CloudProvider): string {
return entry.displayNameByCloud?.[cloud] ?? entry.displayName
}
export function routeFor(entry: ServiceCatalogEntry, cloud?: CloudProvider): string {
const perCloud = cloud ? entry.routeByCloud?.[cloud] : undefined
return perCloud ?? entry.route ?? entry.service
}
export function compareCatalogEntries(a: ServiceCatalogEntry, b: ServiceCatalogEntry): number {
const groupDelta = SERVICE_GROUP_ORDER.indexOf(a.group) - SERVICE_GROUP_ORDER.indexOf(b.group)
return groupDelta !== 0 ? groupDelta : a.order - b.order
}
@@ -0,0 +1,39 @@
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List namespaces', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create namespace', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete namespace', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect namespace', enabled: true, status: 'available', runtimeRequired: false},
]
export function azureServiceBusSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'messaging',
displayName: 'Service Bus',
fields: [
{
name: 'namespaceName',
label: 'Namespace Name',
type: 'text',
required: true,
description: '6-50 characters. Letters, numbers, and hyphens; starts with a letter, ends with a letter or number. Cannot end with -sb or -mgmt.',
validation: {
pattern: '^(?!.*-(?:[sS][bB]|[mM][gG][mM][tT])$)[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$',
minLength: 6,
maxLength: 50,
message: 'Use a valid Service Bus namespace: 6-50 letters, numbers, or hyphens; start with a letter, end with a letter or number, and do not end with -sb or -mgmt.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'Namespace'},
{name: 'status', label: 'Status'},
{name: 'type', label: 'Type'},
],
}
}
+145
View File
@@ -0,0 +1,145 @@
import type {CapabilitySchema, CloudProvider, FieldSchema, ObjectActionName, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
const storageColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'type', label: 'Type'},
{name: 'cloud', label: 'Cloud'},
{name: 'region', label: 'Region'},
{name: 'createdAt', label: 'Created At'},
]
const storageFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false},
]
function storageResourceActions(createLabel: string): CapabilitySchema<ResourceActionName>[] {
return [
{name: 'list', label: 'List resources', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: createLabel, enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete resource', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect resource', enabled: true, status: 'available', runtimeRequired: false},
]
}
const storageObjectActions: CapabilitySchema<ObjectActionName>[] = [
{name: 'list', label: 'List objects', enabled: true, status: 'available', runtimeRequired: true},
{name: 'upload', label: 'Upload object', enabled: true, status: 'available', runtimeRequired: true},
{name: 'download', label: 'Download object', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete object', enabled: true, status: 'available', runtimeRequired: true},
{
name: 'createFolder',
label: 'Create folder',
enabled: true,
status: 'partial',
reason: 'Folders are represented as object/blob prefixes, not filesystem directories.',
runtimeRequired: true,
},
{name: 'copy', label: 'Copy object', enabled: true, status: 'available', runtimeRequired: true},
]
export function awsStorageSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'storage',
displayName: 'S3 Storage',
fields: [
{
name: 'bucketName',
label: 'Bucket Name',
type: 'text',
required: true,
description: '3-63 characters. Lowercase letters, numbers, dots, and hyphens.',
validation: {
pattern: '^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?!.*\\.\\.)(?!.*\\.-)(?!.*-\\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$',
minLength: 3,
maxLength: 63,
message: 'Use a valid S3 bucket name: 3-63 lowercase characters, numbers, dots, or hyphens.',
},
},
{
name: 'region',
label: 'Region',
type: 'select',
required: false,
options: [
{label: 'US East (N. Virginia)', value: 'us-east-1'},
{label: 'US West (Oregon)', value: 'us-west-2'},
],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: storageResourceActions('Create bucket'),
objectActions: storageObjectActions,
},
filters: storageFilters,
columns: storageColumns,
}
}
export function azureStorageSchema(): ServiceSchema {
return {
cloud: 'azure',
service: 'storage',
displayName: 'Azure Blob Storage',
fields: [
{
name: 'containerName',
label: 'Container Name',
type: 'text',
required: true,
description: '3-63 characters. Lowercase letters, numbers, and hyphens.',
validation: {
pattern: '^[a-z0-9](?:[a-z0-9]|-(?!-)){1,61}[a-z0-9]$',
minLength: 3,
maxLength: 63,
message: 'Use a valid Azure container name: 3-63 lowercase letters, numbers, or single hyphens.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: storageResourceActions('Create container'),
objectActions: storageObjectActions,
},
filters: storageFilters,
columns: storageColumns,
}
}
export function gcpStorageSchema(): ServiceSchema {
return {
cloud: 'gcp',
service: 'storage',
displayName: 'Google Cloud Storage',
fields: [
{
name: 'bucketName',
label: 'Bucket Name',
type: 'text',
required: true,
description: '3-63 characters. Lowercase letters, numbers, dots, underscores, and hyphens.',
validation: {
pattern: '^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$',
minLength: 3,
maxLength: 63,
message: 'Use a valid GCS bucket name: 3-63 lowercase characters, numbers, dots, underscores, or hyphens.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: storageResourceActions('Create bucket'),
objectActions: storageObjectActions,
},
filters: storageFilters,
columns: storageColumns,
}
}
export function storageSchemaFor(cloud: CloudProvider): ServiceSchema | null {
if (cloud === 'aws') return awsStorageSchema()
if (cloud === 'azure') return azureStorageSchema()
if (cloud === 'gcp') return gcpStorageSchema()
return null
}
+590
View File
@@ -0,0 +1,590 @@
// Derived from SERVICE_CATALOG so a new catalog row is a new service type.
// This type-only cycle with serviceCatalog.ts is erased at compile time.
import type {CloudServiceType, ServiceGroup} from './serviceCatalog'
import type {
CollectionActionName,
DocumentStoreAdapter,
ItemActionName,
ItemStoreAdapter,
} from './childCollections'
export type {CloudServiceType, ServiceGroup}
export type CloudProvider = 'aws' | 'azure' | 'gcp'
export type CloudAvailability = 'available' | 'coming_soon'
export interface CloudDescriptor {
id: CloudProvider
displayName: string
availability: CloudAvailability
}
/**
* What the frontend needs to render a nav entry. Everything except
* `availability` and `reason` comes from the service catalog; those two are
* resolved per cloud at request time.
*/
export interface CloudServiceDescriptor {
cloud: CloudProvider
service: CloudServiceType
displayName: string
availability: CloudAvailability
/** Why the service is unavailable. Always set when availability is coming_soon. */
reason?: string
/** Route slug, or an absolute path for a page outside Cloud Explorer. */
route: string
/** Icon hint; the frontend maps it to a component and falls back if unknown. */
iconKey: string
group: ServiceGroup
order: number
}
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
export interface CloudStatus {
cloud: CloudProvider
adapterRegistered: boolean
runtime: RuntimeReachability
endpoint: string | null
checkedAt: string
error: string | null
/** Present only when the caller asks for per-service detail. */
services?: CloudServiceStatus[]
}
/**
* Health of one service rather than the whole cloud. Needed because a cloud can
* be up while an individual service is not — the Azure runtime serves blob
* storage but answers 501 for functions.
*/
export interface CloudServiceStatus {
cloud: CloudProvider
service: CloudServiceType
adapterRegistered: boolean
runtime: RuntimeReachability
endpoint: string | null
checkedAt: string
latencyMs: number | null
error: string | null
/** The mapped error code, so the UI can tell "not implemented" from "down". */
errorCode: string | null
}
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file'
export interface FieldSchema {
name: string
label: string
type: FieldType
required: boolean
description?: string
group?: string
span?: boolean
valuePath?: string
defaultValue?: string
defaultValueWhen?: {field: string; values: Record<string, string>}
validation?: {
pattern?: string
minLength?: number
maxLength?: number
message?: string
}
options?: Array<{label: string; value: string}>
accept?: string
}
/**
* Verbs a service can advertise. `ActionSchema` drives which controls the
* generic view renders; `ResourceActionName` additionally covers lifecycle verbs
* that a capability block can describe but that are not table-level controls.
*/
export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect'
export type ResourceActionName =
| 'list'
| 'create'
| 'update'
| 'delete'
| 'inspect'
| 'encrypt'
| 'decrypt'
| 'invoke'
| 'start'
| 'stop'
| 'reboot'
| 'updateTags'
export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy'
export type DatabaseActionName = 'listSnapshots' | 'createSnapshot'
export type KubernetesActionName =
| 'listNodegroups'
| 'createNodegroup'
| 'deleteNodegroup'
| 'listFargateProfiles'
| 'createFargateProfile'
| 'deleteFargateProfile'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
name: TAction
label: string
enabled: boolean
status: CapabilityStatus
reason?: string
runtimeRequired?: boolean
}
/**
* How a column is rendered. Everything past `label` is optional and every
* default reproduces the previous behaviour, so existing schemas are unaffected.
*/
export type ColumnFormat = 'text' | 'datetime' | 'relative' | 'bytes' | 'boolean' | 'badge' | 'code' | 'list'
export interface TableColumnSchema {
/** Column identity and sort key. */
name: string
label: string
/**
* Dotted accessor into the resource, defaulting to `name`. Needed because
* most provider detail lives under `metadata`, which a top-level lookup
* cannot reach — those columns previously rendered blank for every row.
*/
path?: string
format?: ColumnFormat
emptyText?: string
width?: string
}
export interface ServiceSchema {
cloud: CloudProvider
service: CloudServiceType
displayName: string
fields: FieldSchema[]
actions: ActionSchema[]
capabilities?: {
resourceActions?: CapabilitySchema<ResourceActionName>[]
objectActions?: CapabilitySchema<ObjectActionName>[]
databaseActions?: CapabilitySchema<DatabaseActionName>[]
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
// Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event.
collectionActions?: CapabilitySchema<CollectionActionName>[]
itemActions?: CapabilitySchema<ItemActionName>[]
}
filters: FieldSchema[]
columns: TableColumnSchema[]
updateFields?: FieldSchema[]
}
export type KnownResourceType =
| 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'dynamodb-table'
| 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
| 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue'
| 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server'
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group'
export interface CloudResource {
id: string
name: string
cloud: CloudProvider
service: CloudServiceType
/** Provider resource kind. Open so adapters can evolve independently. */
type: KnownResourceType | (string & {})
region: string | null
createdAt: string | null
status?: string | null
version?: string | null
engine?: string | null
instanceClass?: string | null
metadata: Record<string, unknown>
}
export interface DatabaseSnapshot {
id: string
name: string
instanceIdentifier: string | null
status: string | null
engine: string | null
version: string | null
createdAt: string | null
metadata: Record<string, unknown>
}
export interface CreateDatabaseSnapshotInput {
instanceIdentifier: string
snapshotIdentifier: string
}
export interface StorageObject {
key: string
name: string
type: 'folder' | 'object'
size: number | null
lastModified: string | null
metadata: Record<string, unknown>
}
export interface StorageObjectList {
prefix: string
objects: StorageObject[]
}
export interface StorageObjectDownload {
body: BodyInit
contentType: string
contentLength: number | null
}
export interface CosmosContainer {
id: string
name: string
databaseId: string
partitionKeyPath: string
createdAt: string | null
metadata: Record<string, unknown>
}
export interface CosmosItem {
id: string
databaseId: string
containerId: string
partitionKey: string | null
etag: string | null
timestamp: string | null
document: Record<string, unknown>
}
export interface CosmosQueryResult {
items: Array<Record<string, unknown> | string | number | boolean | null>
count: number
}
export interface SqlConnectionInput {
username: string
password: string
database?: string
engine?: 'azure-sql' | 'postgresql'
}
export interface SqlDatabase {
name: string
state: string
createdAt: string | null
isSystem: boolean
}
export interface SqlTable {
schema: string
name: string
type: 'table' | 'view'
rowCount: number | null
}
export interface SqlColumn {
name: string
type: string
}
export interface SqlResultSet {
columns: SqlColumn[]
rows: Array<Record<string, unknown>>
truncated: boolean
}
export interface SqlQueryResult {
// `resultSets` holds only the statements that returned columns, while `rowsAffected`
// has one entry per executed statement. On a multi-statement script the two arrays
// therefore have different lengths — treat `rowsAffected` as a total, never as an
// index-for-index companion to `resultSets`.
resultSets: SqlResultSet[]
rowsAffected: number[]
durationMs: number
}
/** Mirrors the real StartQuery contract: epoch seconds, inclusive range. */
export interface LogsInsightsQueryInput {
queryString: string
startTime: number
endTime: number
limit?: number
}
export interface LogsInsightsQueryResult {
/**
* Always present, including when `status` is still Running/Scheduled after
* the poll deadline — dropping it in that case would leave no way to ever
* check back on that query again, even though it is still running server-side.
*/
queryId: string
/** CloudWatch Logs' own QueryStatus: Complete, Running, Failed, Timeout, ... */
status: string
rows: Array<Record<string, string>>
}
export interface NoSqlItem {
id: string
key: Record<string, unknown>
document: Record<string, unknown>
}
/** Provider-neutral representation of a managed Kubernetes node pool. */
export interface KubernetesNodegroup {
id: string
name: string
clusterId: string
arn: string | null
status: string | null
version: string | null
releaseVersion: string | null
createdAt: string | null
modifiedAt: string | null
capacityType: string | null
instanceTypes: string[]
subnets: string[]
nodeRole: string | null
scalingConfig: {minSize?: number; maxSize?: number; desiredSize?: number} | null
labels: Record<string, string>
tags: Record<string, string>
}
export interface CreateKubernetesNodegroupInput {
name: string
nodeRole: string
subnets: string[]
instanceTypes?: string[]
scalingConfig?: {minSize?: number; maxSize?: number; desiredSize?: number}
labels?: Record<string, string>
tags?: Record<string, string>
}
export interface KubernetesFargateProfile {
id: string
name: string
clusterId: string
arn: string | null
status: string | null
createdAt: string | null
podExecutionRoleArn: string | null
subnets: string[]
selectors: Array<{namespace: string | null; labels: Record<string, string>}>
tags: Record<string, string>
}
export interface CreateKubernetesFargateProfileInput {
name: string
podExecutionRoleArn: string
subnets?: string[]
selectors: Array<{namespace: string; labels?: Record<string, string>}>
tags?: Record<string, string>
}
export interface AppConfigEnvironment {
id: string
applicationId: string
name: string
description: string | null
state: string | null
}
export interface AppConfigConfigurationProfile {
id: string
applicationId: string
name: string
description: string | null
locationUri: string | null
type: string | null
}
export interface AppConfigHostedConfigurationVersion {
id: string
applicationId: string
configurationProfileId: string
versionNumber: number
description: string | null
contentType: string | null
/** Decoded configuration payload; only GetHostedConfigurationVersion returns it. */
content: string | null
}
export interface AppConfigDeploymentStrategy {
id: string
name: string
description: string | null
deploymentDurationInMinutes: number | null
growthType: string | null
growthFactor: number | null
finalBakeTimeInMinutes: number | null
replicateTo: string | null
}
export interface AppConfigDeployment {
applicationId: string
environmentId: string
deploymentNumber: number
configurationProfileId: string | null
configurationVersion: string | null
deploymentStrategyId: string | null
state: string | null
percentageComplete: number | null
startedAt: string | null
completedAt: string | null
description: string | null
}
export interface ResourceQuery {
search?: string
}
export interface CreateResourceInput {
values: Record<string, unknown>
}
export interface UpdateResourceInput {
values: Record<string, unknown>
}
export interface ServerlessInvokeResult {
statusCode: number
payload: string
functionError?: string
logResult?: string
executionDuration?: number
}
export interface ApiGatewayLambdaRouteInput {
path: string
method: string
stage: string
functionName: string
}
export interface ApiGatewayLambdaRouteResult {
resourceId: string
deploymentId: string
invokeUrl: string
}
export type KmsEncryptionAlgorithm =
| 'SYMMETRIC_DEFAULT'
| 'RSAES_OAEP_SHA_1'
| 'RSAES_OAEP_SHA_256'
export interface KmsEncryptInput {
plaintext: Uint8Array
encryptionAlgorithm: KmsEncryptionAlgorithm
encryptionContext?: Record<string, string>
}
export interface KmsEncryptResult {
ciphertextBlob: Uint8Array
keyId: string
encryptionAlgorithm: KmsEncryptionAlgorithm
}
export interface KmsDecryptInput {
ciphertextBlob: Uint8Array
encryptionAlgorithm: KmsEncryptionAlgorithm
encryptionContext?: Record<string, string>
}
export interface KmsDecryptResult {
plaintext: Uint8Array
keyId: string
encryptionAlgorithm: KmsEncryptionAlgorithm
}
/**
* Lets a registered adapter correct its own advertised availability.
*
* Needed because "an adapter exists" and "the local runtime implements it" are
* different facts: floci-az ships no /functions endpoint, so the Azure
* serverless adapter is registered but cannot serve a request. Declaring that
* here keeps the sidebar, the console card and the explorer surface consistent
* from one string.
*/
export interface CloudServiceDescriptorOverride {
availability?: CloudAvailability
reason?: string
displayName?: string
}
export interface CloudServiceAdapter {
readonly cloud: CloudProvider
readonly service: CloudServiceType
schema(): ServiceSchema
descriptorOverride?(): CloudServiceDescriptorOverride
list(query?: ResourceQuery): Promise<CloudResource[]>
get(id: string): Promise<CloudResource | null>
create(input: CreateResourceInput): Promise<CloudResource>
update?(id: string, input: UpdateResourceInput): Promise<CloudResource>
delete(id: string): Promise<void>
listObjects?(resourceId: string, prefix?: string): Promise<StorageObjectList>
putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void>
getObject?(resourceId: string, key: string): Promise<StorageObjectDownload>
deleteObject?(resourceId: string, key: string): Promise<void>
listDatabaseSnapshots?(instanceIdentifier?: string): Promise<DatabaseSnapshot[]>
createDatabaseSnapshot?(input: CreateDatabaseSnapshotInput): Promise<DatabaseSnapshot>
listDatabaseOrderableInstanceClasses?(engine?: string): Promise<string[]>
invoke?(id: string, payload: string): Promise<ServerlessInvokeResult>
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
// Lifecycle verbs. Optional because most categories have no notion of them;
// an adapter that advertises one in `capabilities` must implement it, which
// cloudProxy.test.ts enforces.
start?(id: string): Promise<void>
stop?(id: string, force?: boolean): Promise<void>
reboot?(id: string): Promise<void>
updateTags?(id: string, tags: Record<string, string | null>): Promise<void>
/**
* Cheap liveness check. Defaults to `list()`, which is a valid probe for
* every current adapter; override where listing is expensive.
*/
health?(): Promise<void>
copyObject?(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise<void>
/**
* Child collections. An adapter implements at most one: `documents` when the
* resource holds collections that hold items, `items` when the resource is
* itself the collection. Both is a contract violation — the flat item routes
* would be ambiguous — and cloudProxy.test.ts enforces that.
*/
documents?: DocumentStoreAdapter
items?: ItemStoreAdapter
listCosmosContainers?(databaseId: string): Promise<CosmosContainer[]>
createCosmosContainer?(databaseId: string, input: CreateResourceInput): Promise<CosmosContainer>
deleteCosmosContainer?(databaseId: string, containerId: string): Promise<void>
listCosmosItems?(databaseId: string, containerId: string): Promise<CosmosItem[]>
upsertCosmosItem?(databaseId: string, containerId: string, document: Record<string, unknown>): Promise<CosmosItem>
deleteCosmosItem?(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise<void>
queryCosmosItems?(databaseId: string, containerId: string, query: string): Promise<CosmosQueryResult>
listSqlDatabases?(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]>
listSqlTables?(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]>
querySql?(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult>
queryLogs?(logGroupName: string, input: LogsInsightsQueryInput): Promise<LogsInsightsQueryResult>
listNoSqlItems?(resourceId: string): Promise<NoSqlItem[]>
putNoSqlItem?(resourceId: string, document: Record<string, unknown>): Promise<NoSqlItem>
listKubernetesNodegroups?(clusterId: string): Promise<KubernetesNodegroup[]>
createKubernetesNodegroup?(clusterId: string, input: CreateKubernetesNodegroupInput): Promise<KubernetesNodegroup>
deleteKubernetesNodegroup?(clusterId: string, nodegroupId: string): Promise<void>
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
/** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void>
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
// reuses the full list payloads; only hosted versions (whose content is not
// in List*) and deployments (which are polled) get a getter.
listAppConfigEnvironments?(applicationId: string): Promise<AppConfigEnvironment[]>
createAppConfigEnvironment?(applicationId: string, input: CreateResourceInput): Promise<AppConfigEnvironment>
deleteAppConfigEnvironment?(applicationId: string, environmentId: string): Promise<void>
listAppConfigConfigurationProfiles?(applicationId: string): Promise<AppConfigConfigurationProfile[]>
createAppConfigConfigurationProfile?(applicationId: string, input: CreateResourceInput): Promise<AppConfigConfigurationProfile>
deleteAppConfigConfigurationProfile?(applicationId: string, profileId: string): Promise<void>
listAppConfigHostedConfigurationVersions?(applicationId: string, profileId: string): Promise<AppConfigHostedConfigurationVersion[]>
getAppConfigHostedConfigurationVersion?(applicationId: string, profileId: string, versionNumber: number): Promise<AppConfigHostedConfigurationVersion | null>
createAppConfigHostedConfigurationVersion?(applicationId: string, profileId: string, input: CreateResourceInput): Promise<AppConfigHostedConfigurationVersion>
deleteAppConfigHostedConfigurationVersion?(applicationId: string, profileId: string, versionNumber: number): Promise<void>
listAppConfigDeploymentStrategies?(): Promise<AppConfigDeploymentStrategy[]>
createAppConfigDeploymentStrategy?(input: CreateResourceInput): Promise<AppConfigDeploymentStrategy>
deleteAppConfigDeploymentStrategy?(strategyId: string): Promise<void>
startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment>
getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise<AppConfigDeployment | null>
}
@@ -0,0 +1,83 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/**
* No `status` column, deliberately.
*
* ListStateMachines returns only the ARN, name, type and creation date — real Step
* Functions does the same. Status lives on DescribeStateMachine, so putting it in
* the table would mean one extra call per row. It is surfaced on inspect instead.
*/
const workflowColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'machineType', label: 'Type', path: 'metadata.machineType', format: 'badge'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const workflowFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
/**
* Step Functions accepts a wider character set, but this is the subset AWS
* recommends and the one that is unambiguous in an ARN.
*/
export const STATE_MACHINE_NAME_PATTERN = '^[a-zA-Z0-9_-]{1,80}$'
export function awsWorkflowsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'workflows',
displayName: 'AWS Step Functions',
fields: [
{
name: 'name',
label: 'State Machine Name',
type: 'text',
required: true,
group: 'Required',
validation: {
pattern: STATE_MACHINE_NAME_PATTERN,
minLength: 1,
maxLength: 80,
message: 'Up to 80 characters: letters, digits, hyphens and underscores.',
},
},
{
name: 'roleArn',
label: 'Execution Role ARN',
type: 'text',
required: true,
group: 'Required',
description: 'The IAM role the state machine assumes when it runs.',
validation: {
pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$',
message: 'Must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role.',
},
},
{
name: 'definition',
label: 'Definition (Amazon States Language)',
type: 'textarea',
required: true,
span: true,
group: 'Required',
description: 'JSON with StartAt and States, e.g. {"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List state machines', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true},
{
name: 'create',
label: 'Create state machine',
enabled: true,
status: 'available',
runtimeRequired: true,
},
{name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: workflowFilters,
columns: workflowColumns,
}
}