feat: Step Function + small fixes + more services + color matching the services

This commit is contained in:
Zakaria
2026-09-28 18:38:22 -04:00
parent 0d613ae256
commit 548a15d7ec
23 changed files with 3219 additions and 52 deletions
@@ -9,9 +9,11 @@ import {
ListStateMachinesCommand,
StartExecutionCommand,
StopExecutionCommand,
UpdateStateMachineCommand,
type SFNClient,
} from '@aws-sdk/client-sfn'
import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter'
import {mapAwsSdkError} from './awsErrors'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
@@ -177,13 +179,32 @@ describe('AwsStepFunctionsAdapter', () => {
expect(resource.id).toBe(ARN)
})
test('requires the fields the schema marks required', async () => {
test('passes an explicit definition to the SDK exactly as received', async () => {
const definition = ` ${DEFINITION}\n`
const {client, sent} = runtimeStub()
await new AwsStepFunctionsAdapter(client).create({values: {...validValues, definition}})
expect((sent[0] as CreateStateMachineCommand).input.definition).toBe(definition)
})
test('creates a name-only workflow with a local role and valid initial ASL', async () => {
const {client, sent} = runtimeStub()
await new AwsStepFunctionsAdapter(client).create({values: {name: 'orders'}})
expect((sent[0] as CreateStateMachineCommand).input).toEqual(expect.objectContaining({
name: 'orders',
roleArn: 'arn:aws:iam::000000000000:role/floci-step-functions',
definition: JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}}),
}))
})
test('requires the name the schema marks required', async () => {
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
const cases: Array<[Record<string, unknown>, string]> = [
[{}, 'name is required'],
[{name: 'orders'}, 'roleArn is required'],
[{name: 'orders', roleArn: ROLE}, 'definition is required'],
[{name: 'orders', definition: ''}, 'definition is required'],
]
for (const [values, message] of cases) {
@@ -199,16 +220,22 @@ describe('AwsStepFunctionsAdapter', () => {
)
})
test('rejects a definition missing StartAt or States', async () => {
test('rejects a definition without a string StartAt or object States', async () => {
// Valid JSON is not a valid state machine. The runtime rejects it, but with
// a parser message; naming the missing field is more actionable.
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
await expect(adapter.create({values: {...validValues, definition: '{"States":{}}'}})).rejects.toThrow(
new ValidationError('definition must contain StartAt'),
new ValidationError('definition must contain StartAt as a string'),
)
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done"}'}})).rejects.toThrow(
new ValidationError('definition must contain States'),
new ValidationError('definition must contain States as an object'),
)
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":1,"States":{}}'}})).rejects.toThrow(
new ValidationError('definition must contain StartAt as a string'),
)
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done","States":[]}'}})).rejects.toThrow(
new ValidationError('definition must contain States as an object'),
)
})
@@ -227,6 +254,43 @@ describe('AwsStepFunctionsAdapter', () => {
expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN)
})
test('updates only the definition and reads the state machine back', async () => {
const updatedDefinition = '{\n "StartAt": "Pass",\n "States": {"Pass": {"Type": "Pass", "End": true}}\n}'
const {client, sent} = stubSfn((command) => {
if (command instanceof UpdateStateMachineCommand) return {updateDate: new Date()}
if (command instanceof DescribeStateMachineCommand) return {...described, definition: updatedDefinition}
return {}
})
const resource = await new AwsStepFunctionsAdapter(client).update(ARN, {values: {definition: updatedDefinition}})
expect(sent[0]).toBeInstanceOf(UpdateStateMachineCommand)
expect((sent[0] as UpdateStateMachineCommand).input).toEqual({stateMachineArn: ARN, definition: updatedDefinition})
expect(sent[1]).toBeInstanceOf(DescribeStateMachineCommand)
expect(resource.metadata.definition).toBe(updatedDefinition)
})
test('leaves update SDK failures for the shared AWS error mapper', async () => {
const providerError = Object.assign(new Error('definition is invalid'), {
name: 'InvalidDefinition',
$fault: 'client',
$metadata: {httpStatusCode: 400},
})
const {client} = stubSfn(() => {
throw providerError
})
let caught: unknown
try {
await new AwsStepFunctionsAdapter(client).update(ARN, {values: {definition: DEFINITION}})
} catch (error) {
caught = error
}
expect(caught).toBe(providerError)
expect(mapAwsSdkError(caught)).toBeInstanceOf(ValidationError)
})
test('uses AWS execution commands with input data and history pagination', async () => {
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
const {client, sent} = stubSfn((command) => {
@@ -8,6 +8,7 @@ import {
ListStateMachinesCommand,
StartExecutionCommand,
StopExecutionCommand,
UpdateStateMachineCommand,
type SFNClient,
type StateMachineListItem,
} from '@aws-sdk/client-sfn'
@@ -20,6 +21,7 @@ import type {
CreateResourceInput,
ResourceQuery,
ServiceSchema,
UpdateResourceInput,
} from '../cloud-spi/types'
/**
@@ -91,11 +93,11 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
'name must be up to 80 characters of letters, digits, hyphens and underscores',
)
}
const roleArn = requiredString(input.values.roleArn, 'roleArn')
const roleArn = optionalString(input.values.roleArn) ?? 'arn:aws:iam::000000000000:role/floci-step-functions'
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) {
throw new ValidationError('roleArn must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role')
}
const definition = requiredDefinition(input.values.definition)
const definition = input.values.definition === undefined ? initialDefinition() : requiredDefinition(input.values.definition)
const res = await this.sfn.send(new CreateStateMachineCommand({name, definition, roleArn}))
const arn = res.stateMachineArn
@@ -111,6 +113,15 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id}))
}
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
const definition = requiredDefinition(input.values.definition)
await this.sfn.send(new UpdateStateMachineCommand({stateMachineArn: id, definition}))
const updated = await this.get(id)
if (!updated) throw new RuntimeError(`State machine ${id} was updated but could not be read back`)
return updated
}
async startExecution(stateMachineArn: string, input: StartWorkflowExecutionInput): Promise<{executionArn: string; startDate: Date}> {
const result = await this.sfn.send(new StartExecutionCommand({
stateMachineArn,
@@ -220,21 +231,24 @@ function filterBySearch(resources: CloudResource[], search?: string): CloudResou
* with a JSON parser message; naming the missing field is more actionable.
*/
function requiredDefinition(value: unknown): string {
const raw = requiredString(value, 'definition')
if (typeof value !== 'string') throw new ValidationError('definition must be a string')
if (!value.trim()) throw new ValidationError('definition is required')
let parsed: unknown
try {
parsed = JSON.parse(raw)
parsed = JSON.parse(value)
} catch {
throw new ValidationError('definition must be valid JSON')
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new ValidationError('definition must be a JSON object')
}
for (const field of ['StartAt', 'States'] as const) {
if (!(field in parsed)) throw new ValidationError(`definition must contain ${field}`)
const definition = parsed as Record<string, unknown>
if (typeof definition.StartAt !== 'string') throw new ValidationError('definition must contain StartAt as a string')
if (typeof definition.States !== 'object' || definition.States === null || Array.isArray(definition.States)) {
throw new ValidationError('definition must contain States as an object')
}
return raw
return value
}
function validExecutionInput(value: string): string {
@@ -259,3 +273,12 @@ function requiredString(value: unknown, field: string): string {
if (!trimmed) throw new ValidationError(`${field} is required`)
return trimmed
}
function optionalString(value: unknown): string | undefined {
if (value === undefined || value === null || value === '') return undefined
return requiredString(value, 'roleArn')
}
function initialDefinition(): string {
return JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}})
}
@@ -0,0 +1,17 @@
import {describe, expect, test} from 'bun:test'
import {awsWorkflowsSchema} from './workflowsSchema'
describe('awsWorkflowsSchema', () => {
test('requires a name and truthfully exposes the optional ASL definition', () => {
const schema = awsWorkflowsSchema()
expect(schema.fields.map((field) => field.name)).toEqual(['name', 'definition'])
expect(schema.fields[0]?.required).toBe(true)
expect(schema.fields[1]).toMatchObject({
name: 'definition',
type: 'textarea',
required: false,
span: true,
})
})
})
+6 -17
View File
@@ -40,29 +40,17 @@ export function awsWorkflowsSchema(): ServiceSchema {
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)',
label: 'ASL Definition',
type: 'textarea',
required: true,
required: false,
group: 'Optional',
span: true,
group: 'Required',
description: 'JSON with StartAt and States, e.g. {"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}',
description: 'Complete Amazon States Language JSON. When omitted, a valid starter workflow is created.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
actions: ['list', 'create', 'update', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List state machines', enabled: true, status: 'available', runtimeRequired: true},
@@ -74,6 +62,7 @@ export function awsWorkflowsSchema(): ServiceSchema {
status: 'available',
runtimeRequired: true,
},
{name: 'update', label: 'Update definition', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true},
],
},