diff --git a/docs/coverage/aws/sfn.md b/docs/coverage/aws/sfn.md index fdca184dc..2cd249989 100644 --- a/docs/coverage/aws/sfn.md +++ b/docs/coverage/aws/sfn.md @@ -47,4 +47,4 @@ AWS's `sfn` service · portable interface `driver.SFN` · [AWS index](./README.m ## Not in scope -_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ +- `StopExecution` on an execution that has already closed returns 200 with its original `stopDate` and changes nothing. The API reference documents no error for this case, and the behavior is not verified against live AWS. diff --git a/docs/coverage/nongoals/sfn.md b/docs/coverage/nongoals/sfn.md new file mode 100644 index 000000000..339ac04fb --- /dev/null +++ b/docs/coverage/nongoals/sfn.md @@ -0,0 +1 @@ +- `StopExecution` on an execution that has already closed returns 200 with its original `stopDate` and changes nothing. The API reference documents no error for this case, and the behavior is not verified against live AWS. diff --git a/providers/aws/sfn/errors.go b/providers/aws/sfn/errors.go index 45eb1c1d9..b2246ff77 100644 --- a/providers/aws/sfn/errors.go +++ b/providers/aws/sfn/errors.go @@ -99,6 +99,12 @@ func invalidExecutionInput(msg string) error { return &driver.APIError{Exception: driver.ExInvalidExecutionInput, Err: errors.New(errors.InvalidArgument, msg)} } +// smTypeNotSupported builds the StateMachineTypeNotSupported error an +// operation returns for a state machine type it does not support. +func smTypeNotSupported(msg string) error { + return &driver.APIError{Exception: driver.ExStateMachineTypeNotSupported, Err: errors.New(errors.FailedPrecondition, msg)} +} + // invalidToken builds an InvalidToken-tagged error. func invalidToken(format string, args ...any) error { return &driver.APIError{Exception: driver.ExInvalidToken, Err: errors.Newf(errors.InvalidArgument, format, args...)} diff --git a/providers/aws/sfn/executions.go b/providers/aws/sfn/executions.go index 1023e2dc6..6ed5f10f8 100644 --- a/providers/aws/sfn/executions.go +++ b/providers/aws/sfn/executions.go @@ -218,7 +218,23 @@ func (m *Mock) StartExternal(ctx context.Context, stateMachineARN, input string) return err } +// StartSyncExecution runs an EXPRESS workflow synchronously. Real Step +// Functions does not offer it for STANDARD workflows and rejects them with +// StateMachineTypeNotSupported. func (m *Mock) StartSyncExecution(ctx context.Context, in driver.StartExecutionInput) (*driver.Execution, error) { + sd, err := m.getSM(in.StateMachineArn) + if err != nil { + return nil, err + } + + sd.mu.RLock() + smType := sd.sm.Type + sd.mu.RUnlock() + + if smType != driver.TypeExpress { + return nil, smTypeNotSupported("This operation is not supported by this type of state machine") + } + return m.runExecution(ctx, in, false) } @@ -310,6 +326,10 @@ func (m *Mock) abortExecution(ed *execData, errCode, cause string) (time.Time, * now := m.now() + // Pinned: StopExecution on an already-closed execution returns 200 with the + // stored stop date and changes nothing. The API reference documents no + // error for this case and the behavior is not verified against live AWS. + // // While an execution is still settling (observably RUNNING under AsyncSettle), // Stop aborts it: ABORTED with a stop date, no output, the caller's error/cause // persisted, and the history trimmed to the events already visible plus a diff --git a/providers/aws/sfn/interpreter_test.go b/providers/aws/sfn/interpreter_test.go index f20bf0a6d..769c8d367 100644 --- a/providers/aws/sfn/interpreter_test.go +++ b/providers/aws/sfn/interpreter_test.go @@ -11,12 +11,27 @@ import ( "github.com/stackshy/cloudemu/v2/services/sfn/driver" ) -// createDef registers a state machine with the given verbatim ASL definition. +// createDef registers a STANDARD state machine with the given verbatim ASL +// definition. func createDef(t *testing.T, m *sfn.Mock, name, def string) string { t.Helper() + return createTypedDef(t, m, name, def, driver.TypeStandard) +} + +// createExpressDef registers an EXPRESS state machine, the only type +// StartSyncExecution accepts. +func createExpressDef(t *testing.T, m *sfn.Mock, name, def string) string { + t.Helper() + + return createTypedDef(t, m, name, def, driver.TypeExpress) +} + +func createTypedDef(t *testing.T, m *sfn.Mock, name, def, smType string) string { + t.Helper() + arn, _, _, err := m.CreateStateMachine(context.Background(), driver.CreateStateMachineInput{ - Name: name, Definition: def, RoleArn: "arn:aws:iam::000000000000:role/r", + Name: name, Definition: def, RoleArn: "arn:aws:iam::000000000000:role/r", Type: smType, }) if err != nil { t.Fatalf("CreateStateMachine(%s): %v", name, err) @@ -25,11 +40,11 @@ func createDef(t *testing.T, m *sfn.Mock, name, def string) string { return arn } -// runSync creates a machine and runs one synchronous (instant) execution. +// runSync creates an EXPRESS machine and runs one synchronous execution. func runSync(t *testing.T, m *sfn.Mock, name, def, input string) *driver.Execution { t.Helper() - arn := createDef(t, m, name, def) + arn := createExpressDef(t, m, name, def) exec, err := m.StartSyncExecution(context.Background(), driver.StartExecutionInput{ StateMachineArn: arn, Name: "run", Input: input, diff --git a/providers/aws/sfn/parallel_map_test.go b/providers/aws/sfn/parallel_map_test.go index d42147f57..0a0537821 100644 --- a/providers/aws/sfn/parallel_map_test.go +++ b/providers/aws/sfn/parallel_map_test.go @@ -171,7 +171,7 @@ func TestParallelTaskLambdaRecursionBounded(t *testing.T) { def := `{"StartAt":"P","States":{"P":{"Type":"Parallel","End":true, "Branches":[{"StartAt":"T","States":{"T":{"Type":"Task","Resource":"` + lambdaFuncARN + `","End":true}}}]}}}` - arn := createDef(t, m, "par-recurse", def) + arn := createExpressDef(t, m, "par-recurse", def) maxDepth := 0 m.SetLambdaSyncInvoker(fakeLambda{fn: func(ctx context.Context, _ string, payload []byte) ([]byte, string, error) { diff --git a/providers/aws/sfn/sfn_test.go b/providers/aws/sfn/sfn_test.go index 058a386c2..a4bb031b9 100644 --- a/providers/aws/sfn/sfn_test.go +++ b/providers/aws/sfn/sfn_test.go @@ -641,9 +641,10 @@ func TestAsyncSettleSyncExecutionAndHistory(t *testing.T) { config.WithAccountID("000000000000"), config.WithAsyncSettle())) ctx := context.Background() arn := createSM(t, m, "sm") + expressArn := createExpressDef(t, m, "sm-express", definition) // Synchronous execution returns terminal SUCCEEDED with output, not RUNNING. - sync, err := m.StartSyncExecution(ctx, driver.StartExecutionInput{StateMachineArn: arn, Name: "sync1", Input: `{"k":1}`}) + sync, err := m.StartSyncExecution(ctx, driver.StartExecutionInput{StateMachineArn: expressArn, Name: "sync1", Input: `{"k":1}`}) if err != nil { t.Fatalf("StartSyncExecution: %v", err) } diff --git a/providers/aws/sfn/start_sync_execution_test.go b/providers/aws/sfn/start_sync_execution_test.go new file mode 100644 index 000000000..b70c3bd15 --- /dev/null +++ b/providers/aws/sfn/start_sync_execution_test.go @@ -0,0 +1,93 @@ +package sfn_test + +import ( + "context" + stderrors "errors" + "testing" + + "github.com/stackshy/cloudemu/v2/services/sfn/driver" +) + +// TestStartSyncExecutionRejectsStandard pins that StartSyncExecution is only +// offered for EXPRESS workflows: a STANDARD state machine is rejected with +// StateMachineTypeNotSupported and no execution is recorded. +func TestStartSyncExecutionRejectsStandard(t *testing.T) { + m := newMock(t) + ctx := context.Background() + arn := createSM(t, m, "std") + + _, err := m.StartSyncExecution(ctx, driver.StartExecutionInput{StateMachineArn: arn, Name: "s1"}) + + var apiErr *driver.APIError + if !stderrors.As(err, &apiErr) || apiErr.Exception != driver.ExStateMachineTypeNotSupported { + t.Fatalf("StartSyncExecution(STANDARD) = %v, want StateMachineTypeNotSupported", err) + } + + execs, err := m.ListExecutions(ctx, arn, "") + if err != nil || len(execs) != 0 { + t.Fatalf("ListExecutions = %d, %v; want no execution recorded", len(execs), err) + } +} + +// TestStartSyncExecutionExpress runs an EXPRESS workflow to completion. +func TestStartSyncExecutionExpress(t *testing.T) { + m := newMock(t) + arn := createExpressDef(t, m, "exp", definition) + + exec, err := m.StartSyncExecution(context.Background(), + driver.StartExecutionInput{StateMachineArn: arn, Name: "s1", Input: `{"a":1}`}) + if err != nil { + t.Fatalf("StartSyncExecution(EXPRESS): %v", err) + } + + if exec.Status != driver.ExecStatusSucceeded || exec.Output != `{"a":1}` { + t.Fatalf("execution = %s %q, want SUCCEEDED {\"a\":1}", exec.Status, exec.Output) + } +} + +// TestStartSyncExecutionMissingStateMachine checks existence before type. +func TestStartSyncExecutionMissingStateMachine(t *testing.T) { + m := newMock(t) + + _, err := m.StartSyncExecution(context.Background(), driver.StartExecutionInput{ + StateMachineArn: "arn:aws:states:us-east-1:000000000000:stateMachine:missing", + }) + + var apiErr *driver.APIError + if !stderrors.As(err, &apiErr) || apiErr.Exception != driver.ExStateMachineDoesNotExist { + t.Fatalf("StartSyncExecution(missing) = %v, want StateMachineDoesNotExist", err) + } +} + +// TestStopExecutionTerminalKeepsStopDate pins StopExecution on a closed +// execution: 200 with the original stop date and no state change. The API +// reference documents no error for this case, and the behavior is unverified +// against live AWS. +func TestStopExecutionTerminalKeepsStopDate(t *testing.T) { + m := newMock(t) + ctx := context.Background() + arn := createSM(t, m, "stop-closed") + + exec, err := m.StartExecution(ctx, driver.StartExecutionInput{StateMachineArn: arn, Name: "done"}) + if err != nil || exec.Status != driver.ExecStatusSucceeded { + t.Fatalf("StartExecution = %v, %v; want SUCCEEDED", exec, err) + } + + stopDate, err := m.StopExecution(ctx, exec.ARN, "Halt", "late") + if err != nil { + t.Fatalf("StopExecution(closed): %v", err) + } + + if !stopDate.Equal(exec.StopDate) { + t.Fatalf("stopDate = %v, want the original %v", stopDate, exec.StopDate) + } + + got, err := m.DescribeExecution(ctx, exec.ARN) + if err != nil { + t.Fatalf("DescribeExecution: %v", err) + } + + if got.Status != driver.ExecStatusSucceeded || got.Error != "" || got.Cause != "" { + t.Fatalf("after stop = %s %q/%q, want unchanged SUCCEEDED", got.Status, got.Error, got.Cause) + } +} diff --git a/providers/aws/sfn/task_test.go b/providers/aws/sfn/task_test.go index 6e23596d1..e0eb8bede 100644 --- a/providers/aws/sfn/task_test.go +++ b/providers/aws/sfn/task_test.go @@ -281,7 +281,7 @@ func TestTaskRecursionGuardTerminates(t *testing.T) { m := newMock(t) def := `{"StartAt":"T","States":{"T":{"Type":"Task","Resource":"` + lambdaFuncARN + `","End":true}}}` - arn := createDef(t, m, "recurse", def) + arn := createExpressDef(t, m, "recurse", def) maxDepth := 0 m.SetLambdaSyncInvoker(fakeLambda{fn: func(ctx context.Context, _ string, payload []byte) ([]byte, string, error) { diff --git a/server/aws/sfn/start_sync_execution_test.go b/server/aws/sfn/start_sync_execution_test.go new file mode 100644 index 000000000..c7bb3f832 --- /dev/null +++ b/server/aws/sfn/start_sync_execution_test.go @@ -0,0 +1,63 @@ +package sfn_test + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awssfn "github.com/aws/aws-sdk-go-v2/service/sfn" + sfntypes "github.com/aws/aws-sdk-go-v2/service/sfn/types" + "github.com/aws/smithy-go" +) + +// syncOpts lets StartSyncExecution reach the local server (it uses the +// "sync-" endpoint host prefix). +func syncOpts(o *awssfn.Options) { o.APIOptions = append(o.APIOptions, disableHostPrefix) } + +// TestSDKStartSyncExecutionStandardTypeNotSupported pins the wire error a real +// SDK sees for StartSyncExecution on a STANDARD state machine. +func TestSDKStartSyncExecutionStandardTypeNotSupported(t *testing.T) { + ctx := context.Background() + c := newSFNClient(t) + arn := createSM(t, c, "std-sync") + + _, err := c.StartSyncExecution(ctx, &awssfn.StartSyncExecutionInput{StateMachineArn: aws.String(arn)}, syncOpts) + + var notSupported *sfntypes.StateMachineTypeNotSupported + if !errors.As(err, ¬Supported) { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + t.Fatalf("want StateMachineTypeNotSupported, got %q", apiErr.ErrorCode()) + } + + t.Fatalf("want StateMachineTypeNotSupported, got %v", err) + } +} + +// TestSDKStartSyncExecutionExpress runs an EXPRESS workflow over the wire. +func TestSDKStartSyncExecutionExpress(t *testing.T) { + ctx := context.Background() + c := newSFNClient(t) + + created, err := c.CreateStateMachine(ctx, &awssfn.CreateStateMachineInput{ + Name: aws.String("exp-sync"), + Definition: aws.String(definition), + RoleArn: aws.String("arn:aws:iam::123456789012:role/svc"), + Type: sfntypes.StateMachineTypeExpress, + }) + if err != nil { + t.Fatalf("CreateStateMachine: %v", err) + } + + out, err := c.StartSyncExecution(ctx, &awssfn.StartSyncExecutionInput{ + StateMachineArn: created.StateMachineArn, Input: aws.String(`{"k":1}`), + }, syncOpts) + if err != nil { + t.Fatalf("StartSyncExecution(EXPRESS): %v", err) + } + + if out.Status != sfntypes.SyncExecutionStatusSucceeded || aws.ToString(out.Output) != `{"k":1}` { + t.Fatalf("got %s %q, want SUCCEEDED {\"k\":1}", out.Status, aws.ToString(out.Output)) + } +} diff --git a/services/sfn/driver/errors.go b/services/sfn/driver/errors.go index 99f309e8b..1d93169bb 100644 --- a/services/sfn/driver/errors.go +++ b/services/sfn/driver/errors.go @@ -22,6 +22,9 @@ const ( ExConflict = "ConflictException" ExValidation = "ValidationException" ExMissingRequiredParameter = "MissingRequiredParameter" + // ExStateMachineTypeNotSupported is returned when an operation does not + // support the state machine's type, e.g. StartSyncExecution on STANDARD. + ExStateMachineTypeNotSupported = "StateMachineTypeNotSupported" ) // APIError tags a canonical cloudemu error with the SFN exception name it