first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user