feat: Step Function + small fixes + more services + color matching the services
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user