Skip to content

Commit e34a662

Browse files
committed
Add real-time CloudFormation event streaming to deploy command
- Deploy command now waits for stack operations to complete - Added real-time CloudFormation event streaming during deployments - Smart create vs update detection - automatically chooses correct operation - Enhanced error handling with NoChangesError for graceful no-changes scenarios Core Changes: - Added DeployStackWithCallback method for event streaming - Added StackEvent struct and event processing capabilities - Added WaitForStackOperation for polling with event callbacks - Moved user presentation logic from AWS layer to deployer layer - Eliminated redundant AWS API calls Architecture Improvements: - Clean separation: AWS layer handles pure operations, deployer handles presentation - Added comprehensive test coverage with new cloudformation_test.go - Updated all mock implementations to support new interfaces - Enhanced documentation with usage examples and testing patterns User Experience: - Real-time progress feedback during stack deployments - Clear messaging for create vs update operations - Graceful handling of 'no changes' updates - Formatted event output showing resource status and progress
1 parent fde71c7 commit e34a662

File tree

10 files changed

+1047
-27
lines changed

10 files changed

+1047
-27
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ Stackaroo simplifies CloudFormation stack management by providing:
3131
- Parameter validation against template requirements
3232
- Circular dependency detection
3333

34+
### Real-time Event Streaming
35+
36+
- Live CloudFormation events during deployment operations
37+
- See resource creation, updates, and completion status in real-time
38+
- Smart detection of create vs update operations
39+
- Graceful handling of "no changes" scenarios
40+
3441
## Installation
3542

3643
```bash

docs/architecture/aws-client.md

Lines changed: 188 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ The operations implement the `CloudFormationOperations` interface:
7777

7878
type CloudFormationOperations interface {
7979
DeployStack(ctx context.Context, input DeployStackInput) error
80+
DeployStackWithCallback(ctx context.Context, input DeployStackInput, eventCallback func(StackEvent)) error
8081
UpdateStack(ctx context.Context, input UpdateStackInput) error
8182
DeleteStack(ctx context.Context, input DeleteStackInput) error
8283
GetStack(ctx context.Context, stackName string) (*Stack, error)
@@ -85,14 +86,17 @@ type CloudFormationOperations interface {
8586
StackExists(ctx context.Context, stackName string) (bool, error)
8687
GetTemplate(ctx context.Context, stackName string) (string, error)
8788
DescribeStack(ctx context.Context, stackName string) (*StackInfo, error)
89+
DescribeStackEvents(ctx context.Context, stackName string) ([]StackEvent, error)
90+
WaitForStackOperation(ctx context.Context, stackName string, eventCallback func(StackEvent)) error
8891
CreateChangeSet(ctx context.Context, params *cloudformation.CreateChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.CreateChangeSetOutput, error)
8992
DeleteChangeSet(ctx context.Context, params *cloudformation.DeleteChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DeleteChangeSetOutput, error)
9093
DescribeChangeSet(ctx context.Context, params *cloudformation.DescribeChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeChangeSetOutput, error)
9194
}
9295
```
9396

9497
**Core Operations:**
95-
- `DeployStack()` - Create new CloudFormation stacks
98+
- `DeployStack()` - Create or update CloudFormation stacks (simple version)
99+
- `DeployStackWithCallback()` - Create or update stacks with real-time event streaming
96100
- `UpdateStack()` - Update existing stacks
97101
- `DeleteStack()` - Delete stacks
98102
- `GetStack()` - Retrieve stack information
@@ -101,15 +105,19 @@ type CloudFormationOperations interface {
101105
- `StackExists()` - Check stack existence
102106
- `GetTemplate()` - Retrieve template content for existing stacks
103107
- `DescribeStack()` - Get detailed stack information including template
108+
- `DescribeStackEvents()` - Retrieve CloudFormation events for a stack
109+
- `WaitForStackOperation()` - Wait for stack operation completion with event streaming
104110
- `CreateChangeSet()` - Create CloudFormation changesets for diff operations
105111
- `DeleteChangeSet()` - Remove CloudFormation changesets
106112
- `DescribeChangeSet()` - Get changeset details and proposed changes
107113

108114
**Data Types:**
109115
- `Stack` - Represents CloudFormation stack with cleaned-up fields
110116
- `StackInfo` - Detailed stack information including template content
117+
- `StackEvent` - Represents CloudFormation stack events with timestamp and resource information
111118
- `Parameter` - Key-value pairs for stack parameters
112119
- `StackStatus` - Enumerated stack status values
120+
- `NoChangesError` - Special error type indicating no changes need to be deployed
113121
- Input structs for each operation with required and optional fields
114122

115123
**Interface Design:**
@@ -139,8 +147,14 @@ client, err := aws.NewDefaultClient(ctx, aws.Config{
139147
// Get CloudFormation operations (returns interface)
140148
cfnOps := client.NewCloudFormationOperations()
141149

142-
// Deploy a stack
143-
err := cfnOps.DeployStack(ctx, aws.DeployStackInput{
150+
// Deploy a stack with event streaming
151+
eventCallback := func(event aws.StackEvent) {
152+
timestamp := event.Timestamp.Format("2006-01-02 15:04:05")
153+
fmt.Printf("[%s] %-20s %-40s %s\n",
154+
timestamp, event.ResourceStatus, event.ResourceType, event.LogicalResourceId)
155+
}
156+
157+
err := cfnOps.DeployStackWithCallback(ctx, aws.DeployStackInput{
144158
StackName: "my-stack",
145159
TemplateBody: templateContent,
146160
Parameters: []aws.Parameter{
@@ -149,6 +163,19 @@ err := cfnOps.DeployStack(ctx, aws.DeployStackInput{
149163
Tags: map[string]string{
150164
"Project": "stackaroo",
151165
},
166+
}, eventCallback)
167+
168+
// Handle no changes scenario
169+
if errors.As(err, &aws.NoChangesError{}) {
170+
fmt.Println("Stack is already up to date - no changes to deploy")
171+
} else if err != nil {
172+
return fmt.Errorf("deployment failed: %w", err)
173+
}
174+
175+
// Simple deployment without events
176+
err := cfnOps.DeployStack(ctx, aws.DeployStackInput{
177+
StackName: "my-stack",
178+
TemplateBody: templateContent,
152179
})
153180

154181
// Check stack status
@@ -170,6 +197,86 @@ result, err := cfnClient.DescribeStackEvents(ctx, &cloudformation.DescribeStackE
170197
})
171198
```
172199

200+
## Advanced Deployment Features
201+
202+
### Smart Create vs Update Detection
203+
204+
The `DeployStack()` and `DeployStackWithCallback()` methods automatically detect whether a stack exists and perform the appropriate operation:
205+
206+
- **New stacks**: Calls CloudFormation `CreateStack` operation
207+
- **Existing stacks**: Calls CloudFormation `UpdateStack` operation
208+
- **No changes needed**: Returns `NoChangesError` for graceful handling
209+
210+
```go
211+
// This automatically determines create vs update
212+
err := cfnOps.DeployStack(ctx, aws.DeployStackInput{
213+
StackName: "my-stack",
214+
TemplateBody: templateContent,
215+
})
216+
217+
// Handle the no changes scenario
218+
var noChangesErr aws.NoChangesError
219+
if errors.As(err, &noChangesErr) {
220+
fmt.Printf("Stack %s is already up to date\n", noChangesErr.StackName)
221+
return nil
222+
}
223+
```
224+
225+
### Real-time Event Streaming
226+
227+
The `DeployStackWithCallback()` method provides real-time CloudFormation event streaming during deployment operations:
228+
229+
```go
230+
// Define event callback for real-time feedback
231+
eventCallback := func(event aws.StackEvent) {
232+
timestamp := event.Timestamp.Format("2006-01-02 15:04:05")
233+
fmt.Printf("[%s] %-20s %-40s %s %s\n",
234+
timestamp,
235+
event.ResourceStatus,
236+
event.ResourceType,
237+
event.LogicalResourceId,
238+
event.ResourceStatusReason,
239+
)
240+
}
241+
242+
// Deploy with event streaming
243+
err := cfnOps.DeployStackWithCallback(ctx, deployInput, eventCallback)
244+
```
245+
246+
**Event Output Example:**
247+
```
248+
Starting create operation for stack my-app...
249+
[2025-01-09 15:30:45] CREATE_IN_PROGRESS AWS::CloudFormation::Stack my-app User Initiated
250+
[2025-01-09 15:30:46] CREATE_IN_PROGRESS AWS::S3::Bucket AppBucket
251+
[2025-01-09 15:30:48] CREATE_COMPLETE AWS::S3::Bucket AppBucket
252+
[2025-01-09 15:30:50] CREATE_IN_PROGRESS AWS::Lambda::Function AppFunction
253+
[2025-01-09 15:30:55] CREATE_COMPLETE AWS::Lambda::Function AppFunction
254+
[2025-01-09 15:30:56] CREATE_COMPLETE AWS::CloudFormation::Stack my-app
255+
Stack my-app create completed successfully
256+
```
257+
258+
### Operation Waiting and Polling
259+
260+
The deployment operations automatically wait for completion:
261+
262+
- **Polling interval**: 5 seconds between status checks
263+
- **Event deduplication**: Only new events are reported via callback
264+
- **Completion detection**: Monitors stack status for terminal states
265+
- **Error handling**: Distinguishes between successful and failed operations
266+
267+
```go
268+
// This will wait until the operation completes or fails
269+
err := cfnOps.WaitForStackOperation(ctx, "my-stack", eventCallback)
270+
if err != nil {
271+
// Handle deployment failure
272+
return fmt.Errorf("stack operation failed: %w", err)
273+
}
274+
```
275+
276+
**Terminal Stack States:**
277+
- **Success**: `CREATE_COMPLETE`, `UPDATE_COMPLETE`, `DELETE_COMPLETE`
278+
- **Failure**: `CREATE_FAILED`, `UPDATE_FAILED`, `ROLLBACK_COMPLETE`, etc.
279+
173280
## Configuration Hierarchy
174281

175282
The client respects the standard AWS configuration hierarchy:
@@ -196,6 +303,24 @@ Common error scenarios are identified and handled appropriately:
196303
- **Permission denied**: Clear indication of IAM policy issues
197304
- **Template validation**: Detailed error messages for template problems
198305
- **Rate limiting**: Retryable errors with appropriate backoff
306+
- **No changes needed**: Special `NoChangesError` type for update operations with no changes
307+
308+
### Special Error Types
309+
310+
#### NoChangesError
311+
When updating a stack that requires no changes, a special `NoChangesError` is returned:
312+
313+
```go
314+
type NoChangesError struct {
315+
StackName string
316+
}
317+
318+
func (e NoChangesError) Error() string {
319+
return fmt.Sprintf("stack %s is already up to date - no changes to deploy", e.StackName)
320+
}
321+
```
322+
323+
This allows applications to distinguish between actual deployment failures and successful "no changes" scenarios.
199324

200325
### Error Examples
201326

@@ -209,6 +334,17 @@ if err != nil {
209334
// Handle other errors
210335
}
211336
}
337+
338+
// Handle deployment with no changes
339+
err := cfnOps.DeployStack(ctx, deployInput)
340+
if err != nil {
341+
var noChangesErr aws.NoChangesError
342+
if errors.As(err, &noChangesErr) {
343+
fmt.Printf("Stack %s is already up to date\n", noChangesErr.StackName)
344+
return nil // This is success, not an error
345+
}
346+
return fmt.Errorf("deployment failed: %w", err)
347+
}
212348
```
213349

214350
## Extension Points
@@ -279,6 +415,21 @@ func (m *MockCloudFormationOperations) DeployStack(ctx context.Context, input aw
279415
args := m.Called(ctx, input)
280416
return args.Error(0)
281417
}
418+
419+
func (m *MockCloudFormationOperations) DeployStackWithCallback(ctx context.Context, input aws.DeployStackInput, eventCallback func(aws.StackEvent)) error {
420+
args := m.Called(ctx, input, eventCallback)
421+
return args.Error(0)
422+
}
423+
424+
func (m *MockCloudFormationOperations) DescribeStackEvents(ctx context.Context, stackName string) ([]aws.StackEvent, error) {
425+
args := m.Called(ctx, stackName)
426+
return args.Get(0).([]aws.StackEvent), args.Error(1)
427+
}
428+
429+
func (m *MockCloudFormationOperations) WaitForStackOperation(ctx context.Context, stackName string, eventCallback func(aws.StackEvent)) error {
430+
args := m.Called(ctx, stackName, eventCallback)
431+
return args.Error(0)
432+
}
282433
```
283434

284435
#### Integration with Business Logic
@@ -299,6 +450,40 @@ deployer := deploy.NewAWSDeployer(mockClient)
299450
- Test business logic in isolation from AWS SDK
300451
- Fast, deterministic tests with no external dependencies
301452

453+
#### Event Callback Testing
454+
When testing operations with event callbacks, use function type matchers:
455+
456+
```go
457+
// Test deployment with event streaming
458+
mockCfnOps.On("DeployStackWithCallback",
459+
ctx,
460+
mock.MatchedBy(func(input aws.DeployStackInput) bool {
461+
return input.StackName == "test-stack"
462+
}),
463+
mock.AnythingOfType("func(aws.StackEvent)"), // Event callback matcher
464+
).Return(nil)
465+
466+
// Test event callback invocation
467+
var capturedEvents []aws.StackEvent
468+
eventCallback := func(event aws.StackEvent) {
469+
capturedEvents = append(capturedEvents, event)
470+
}
471+
472+
err := cfnOps.DeployStackWithCallback(ctx, input, eventCallback)
473+
assert.NoError(t, err)
474+
assert.Len(t, capturedEvents, expectedEventCount)
475+
```
476+
477+
#### Testing NoChangesError
478+
```go
479+
// Test no changes scenario
480+
mockCfnOps.On("DeployStackWithCallback", ctx, input, mock.AnythingOfType("func(aws.StackEvent)")).
481+
Return(aws.NoChangesError{StackName: "test-stack"})
482+
483+
err := deployer.DeployStack(ctx, stack)
484+
assert.NoError(t, err) // Should handle NoChangesError gracefully
485+
```
486+
302487
### Integration Testing
303488
- Use AWS localstack or moto for local AWS service simulation
304489
- Provide test configuration for different AWS environments

internal/aws/client_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ func (m *MockCloudFormationClient) DescribeChangeSet(ctx context.Context, params
111111
return nil, nil
112112
}
113113

114+
func (m *MockCloudFormationClient) DescribeStackEvents(ctx context.Context, params *cloudformation.DescribeStackEventsInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStackEventsOutput, error) {
115+
return nil, nil
116+
}
117+
114118
func TestConfig_RegionHandling(t *testing.T) {
115119
tests := []struct {
116120
name string

0 commit comments

Comments
 (0)