Skip to content
Open
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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
fail-fast: false
matrix:
os: [ ubuntu ]
ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4' ]
ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0' ]
include:
- os: macos
ruby: '2.7'
Expand Down
4 changes: 2 additions & 2 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ Metrics/CyclomaticComplexity:
- 'lib/stack_master/aws_driver/s3.rb'
- 'lib/stack_master/cli.rb'
- 'lib/stack_master/parameter_resolvers/latest_container.rb'
- 'lib/stack_master/stack.rb'
- 'lib/stack_master/stack_definition.rb'
- 'lib/stack_master/stack_events/streamer.rb'

Expand All @@ -276,8 +277,6 @@ Metrics/MethodLength:
- 'lib/stack_master/config.rb'
- 'lib/stack_master/paged_response_accumulator.rb'
- 'lib/stack_master/parameter_resolvers/latest_container.rb'
- 'lib/stack_master/parameter_resolvers/stack_output.rb'
- 'lib/stack_master/parameter_validator.rb'
- 'lib/stack_master/prompter.rb'
- 'lib/stack_master/security_group_finder.rb'
- 'lib/stack_master/sso_group_id_finder.rb'
Expand Down Expand Up @@ -306,6 +305,7 @@ Metrics/PerceivedComplexity:
Exclude:
- 'lib/stack_master/aws_driver/s3.rb'
- 'lib/stack_master/cli.rb'
- 'lib/stack_master/stack.rb'
- 'lib/stack_master/stack_definition.rb'

Naming/AccessorMethodName:
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ The format is based on [Keep a Changelog], and this project adheres to

### Changed

- Resolve style issues identified by RuboCop ([#396])
- Improve message when CloudFormation claims there are no changes to apply, even when a template diff is present. ([#398])
- Display Tags diff (stack tags) in `stack_master diff` and `stack_master apply` commands. ([#397])
- Resolve style issues identified by RuboCop. ([#396])
- Test on Ruby 4.0 in the CI build ([#399]).

[Unreleased]: https://github.com/envato/stack_master/compare/v2.17.1...HEAD
[#396]: https://github.com/envato/stack_master/pull/396
[#397]: https://github.com/envato/stack_master/pull/397
[#398]: https://github.com/envato/stack_master/pull/398
[#399]: https://github.com/envato/stack_master/pull/399

## [2.17.1] - 2025-12-19

Expand Down
74 changes: 74 additions & 0 deletions features/diff.feature
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,77 @@ Feature: Diff command
| + "GroupDescription": "Test SG 2", |
Then the exit status should be 0

Scenario: Run diff showing tags added when current stack has no tags
Given a file named "stack_master.yml" with:
"""
stacks:
us_east_1:
myapp_vpc:
template: myapp_vpc.json
tags:
Application: myapp
Environment: staging
"""
And a directory named "parameters"
And a file named "parameters/myapp_vpc.yml" with:
"""
KeyName: my-key
"""
And a directory named "templates"
And a file named "templates/myapp_vpc.json" with:
"""
{
"Description": "Test template",
"AWSTemplateFormatVersion": "2010-09-09",
"Parameters": {
"KeyName": {
"Description": "Key Name",
"Type": "String"
}
},
"Resources": {
"TestSg": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupDescription": "Test SG",
"VpcId": {
"Ref": "VpcId"
}
}
}
}
}
"""
And I stub the following stacks:
| stack_id | stack_name | parameters | region |
| 1 | myapp-vpc | KeyName=changed-key | us-east-1 |
And I stub a template for the stack "myapp-vpc":
"""
{
"Description": "Test template",
"AWSTemplateFormatVersion": "2010-09-09",
"Parameters": {
"KeyName": {
"Description": "Key Name",
"Type": "String"
}
},
"Resources": {
"TestSg": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupDescription": "Test SG",
"VpcId": {
"Ref": "VpcId"
}
}
}
}
}
"""
When I run `stack_master diff us-east-1 myapp-vpc --trace`
Then the output should contain all of these lines:
| Tags diff: |
| +Application: myapp |
| +Environment: staging |
And the exit status should be 0
1 change: 1 addition & 0 deletions features/step_definitions/stack_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def extract_hash_from_kv_string(string)
table.hashes.each do |row|
row.symbolize_keys!
row[:parameters] = StackMaster::Utils.hash_to_aws_parameters(extract_hash_from_kv_string(row[:parameters]))
row[:tags] = StackMaster::Utils.hash_to_aws_tags(extract_hash_from_kv_string(row[:tags])) if row.key?(:tags)
outputs = extract_hash_from_kv_string(row[:outputs]).each_with_object([]) do |(k, v), array|
array << OpenStruct.new(output_key: k, output_value: v)
end
Expand Down
19 changes: 17 additions & 2 deletions lib/stack_master/commands/apply.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def create_stack_by_change_set
@change_set = ChangeSet.create(stack_options.merge(change_set_type: 'CREATE'))
if @change_set.failed?
ChangeSet.delete(@change_set.id)
halt!(@change_set.status_reason)
halt!(user_friendly_changeset_error(@change_set.status_reason))
end

@change_set.display(StackMaster.stdout)
Expand Down Expand Up @@ -123,7 +123,7 @@ def update_stack
@change_set = ChangeSet.create(stack_options)
if @change_set.failed?
ChangeSet.delete(@change_set.id)
halt!(@change_set.status_reason)
halt!(user_friendly_changeset_error(@change_set.status_reason))
end

@change_set.display(StackMaster.stdout)
Expand Down Expand Up @@ -230,6 +230,21 @@ def set_stack_policy
StackMaster.stdout.puts 'done.'
end

def user_friendly_changeset_error(status_reason)
# CloudFormation returns various messages when there are no changes to apply
if status_reason =~ /didn'?t contain changes|no changes|no updates are to be performed/i
<<~MESSAGE.chomp
#{status_reason}

While there may be differences in the template file (e.g., whitespace, comments, or
formatting), CloudFormation has determined that no actual resource changes are needed.
The stack is already in the desired state.
MESSAGE
else
status_reason
end
end

extend Forwardable
def_delegators :@stack_definition, :stack_name, :region
end
Expand Down
4 changes: 4 additions & 0 deletions lib/stack_master/stack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ def self.find(region, stack_name)
template_format = TemplateUtils.identify_template_format(template_body)
stack_policy_body ||= cf.get_stack_policy({ stack_name: stack_name }).stack_policy_body
outputs = cf_stack.outputs
tags = cf_stack.tags&.each_with_object({}) do |tag_struct, tags_hash|
tags_hash[tag_struct.key] = tag_struct.value
end || {}

new(region: region,
stack_name: stack_name,
stack_id: cf_stack.stack_id,
parameters: parameters,
tags: tags,
template_body: template_body,
template_format: template_format,
outputs: outputs,
Expand Down
25 changes: 25 additions & 0 deletions lib/stack_master/stack_differ.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,34 @@ def parameters_diff
after: proposed_parameters)
end

def tags_different?
tags_diff.different?
end

def tags_diff
@tags_diff ||= Diff.new(name: 'Tags',
before: current_tags,
after: proposed_tags)
end

def current_tags
tags_hash = @current_stack&.tags
return '' if tags_hash.nil? || tags_hash.empty?

YAML.dump(sort_params(tags_hash))
end

def proposed_tags
tags_hash = @proposed_stack.tags
return '' if tags_hash.nil? || tags_hash.empty?

YAML.dump(sort_params(tags_hash))
end

def output_diff
body_diff.display
parameters_diff.display
tags_diff.display

StackMaster.stdout.puts ' * can not tell if NoEcho parameters are different.' unless noecho_keys.empty?
StackMaster.stdout.puts 'No stack found' if @current_stack.nil?
Expand Down
8 changes: 8 additions & 0 deletions lib/stack_master/test_driver/cloud_formation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ def parameters
parameter_value: hash[:parameter_value])
end
end

def tags
return [] if @tags.nil?

@tags.map do |hash|
OpenStruct.new(key: hash[:key], value: hash[:value])
end
end
end

class StackEvent
Expand Down
37 changes: 34 additions & 3 deletions spec/stack_master/commands/apply_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,42 @@ def apply
before do
allow(StackMaster::ChangeSet).to receive(:delete)
allow(change_set).to receive(:failed?).and_return(true)
allow(change_set).to receive(:status_reason).and_return('reason')
end

it 'outputs the status reason' do
expect { apply }.to output(/reason/).to_stdout
context 'with a generic error' do
before do
allow(change_set).to receive(:status_reason).and_return('reason')
end

it 'outputs the status reason' do
expect { apply }.to output(/reason/).to_stdout
end
end

context 'with a "no changes" error from CloudFormation' do
before do
allow(change_set)
.to receive(:status_reason)
.and_return("The submitted information didn't contain changes. " \
'Submit different information to create a change set.')
end

it 'outputs a user-friendly explanation' do
expect { apply }.to output(/The submitted information didn't contain changes/).to_stdout
expect { apply }.to output(/no actual resource changes are needed/).to_stdout
expect { apply }.to output(/stack is already in the desired state/).to_stdout
end
end

context 'with alternative "no changes" error message' do
before do
allow(change_set).to receive(:status_reason).and_return('No updates are to be performed.')
end

it 'outputs a user-friendly explanation' do
expect { apply }.to output(/No updates are to be performed/).to_stdout
expect { apply }.to output(/no actual resource changes are needed/).to_stdout
end
end
end

Expand Down
88 changes: 88 additions & 0 deletions spec/stack_master/stack_differ_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,94 @@
expect { differ.output_diff }.to_not output(/No stack found/).to_stdout
end
end

context 'tags diff' do
context 'when tags are added on a new proposal' do
let(:stack) do
StackMaster::Stack.new(
stack_name: stack_name,
region: region,
template_body: '{}',
template_format: :json,
parameters: {},
tags: {}
)
end
let(:proposed_stack) do
StackMaster::Stack.new(
stack_name: stack_name,
region: region,
template_body: '{}',
template_format: :json,
parameters: {},
tags: { 'Application' => 'myapp', 'Environment' => 'staging' }
)
end

it 'prints a tags diff header and one-line additions for each tag' do
expect { differ.output_diff }.to output(/Tags diff:/).to_stdout
expect { differ.output_diff }.to output(/\+Application: myapp/).to_stdout
expect { differ.output_diff }.to output(/\+Environment: staging/).to_stdout
end
end

context 'when tags are unchanged and empty' do
let(:stack) do
StackMaster::Stack.new(
stack_name: stack_name,
region: region,
template_body: '{}',
template_format: :json,
parameters: {},
tags: {}
)
end
let(:proposed_stack) do
StackMaster::Stack.new(
stack_name: stack_name,
region: region,
template_body: '{}',
template_format: :json,
parameters: {},
tags: {}
)
end

it 'prints Tags diff: No changes' do
expect { differ.output_diff }.to output(/Tags diff: No changes/).to_stdout
end
end

context 'when tags are modified with additions and removals' do
let(:stack) do
StackMaster::Stack.new(
stack_name: stack_name,
region: region,
template_body: '{}',
template_format: :json,
parameters: {},
tags: { 'Application' => 'old', 'Environment' => 'staging' }
)
end
let(:proposed_stack) do
StackMaster::Stack.new(
stack_name: stack_name,
region: region,
template_body: '{}',
template_format: :json,
parameters: {},
tags: { 'Application' => 'new', 'Owner' => 'team' }
)
end

it 'prints +/- lines for changed/added/removed tags, one per line' do
expect { differ.output_diff }.to output(/-Application: old/).to_stdout
expect { differ.output_diff }.to output(/\+Application: new/).to_stdout
expect { differ.output_diff }.to output(/-Environment: staging/).to_stdout
expect { differ.output_diff }.to output(/\+Owner: team/).to_stdout
end
end
end
end

describe '#single_param_update?' do
Expand Down
Loading