Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/coverage/aws/sfn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/coverage/nongoals/sfn.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions providers/aws/sfn/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)}
Expand Down
20 changes: 20 additions & 0 deletions providers/aws/sfn/executions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions providers/aws/sfn/interpreter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion providers/aws/sfn/parallel_map_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion providers/aws/sfn/sfn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
93 changes: 93 additions & 0 deletions providers/aws/sfn/start_sync_execution_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion providers/aws/sfn/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions server/aws/sfn/start_sync_execution_test.go
Original file line number Diff line number Diff line change
@@ -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, &notSupported) {
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))
}
}
3 changes: 3 additions & 0 deletions services/sfn/driver/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading