From 51457b64854ba6eae9f1a358cd5f89f14fcfc69b Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:21:15 +0700 Subject: [PATCH 1/3] Display tags diff during diff and apply commands --- .rubocop_todo.yml | 4 +- CHANGELOG.md | 4 +- features/diff.feature | 74 ++++++++++++++++ features/step_definitions/stack_steps.rb | 1 + lib/stack_master/stack.rb | 4 + lib/stack_master/stack_differ.rb | 25 ++++++ .../test_driver/cloud_formation.rb | 8 ++ spec/stack_master/stack_differ_spec.rb | 88 +++++++++++++++++++ spec/stack_master/stack_spec.rb | 8 ++ 9 files changed, 213 insertions(+), 3 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 488b9032..b213222f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -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' @@ -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' @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 110c2e67..77ccbe15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,12 @@ The format is based on [Keep a Changelog], and this project adheres to ### Changed -- Resolve style issues identified by RuboCop ([#396]) +- Display Tags diff (stack tags) in `stack_master diff` and `stack_master apply` commands. ([#397]) +- Resolve style issues identified by RuboCop. ([#396]) [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 ## [2.17.1] - 2025-12-19 diff --git a/features/diff.feature b/features/diff.feature index a7c29d0c..370d3390 100644 --- a/features/diff.feature +++ b/features/diff.feature @@ -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 diff --git a/features/step_definitions/stack_steps.rb b/features/step_definitions/stack_steps.rb index 44ca2902..a13006e1 100644 --- a/features/step_definitions/stack_steps.rb +++ b/features/step_definitions/stack_steps.rb @@ -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 diff --git a/lib/stack_master/stack.rb b/lib/stack_master/stack.rb index ed2f0c9f..9a629d00 100644 --- a/lib/stack_master/stack.rb +++ b/lib/stack_master/stack.rb @@ -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, diff --git a/lib/stack_master/stack_differ.rb b/lib/stack_master/stack_differ.rb index c868905b..a07a69fb 100644 --- a/lib/stack_master/stack_differ.rb +++ b/lib/stack_master/stack_differ.rb @@ -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? diff --git a/lib/stack_master/test_driver/cloud_formation.rb b/lib/stack_master/test_driver/cloud_formation.rb index 940b6efd..baca3680 100644 --- a/lib/stack_master/test_driver/cloud_formation.rb +++ b/lib/stack_master/test_driver/cloud_formation.rb @@ -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 diff --git a/spec/stack_master/stack_differ_spec.rb b/spec/stack_master/stack_differ_spec.rb index 9aeeaf1e..805b86e5 100644 --- a/spec/stack_master/stack_differ_spec.rb +++ b/spec/stack_master/stack_differ_spec.rb @@ -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 diff --git a/spec/stack_master/stack_spec.rb b/spec/stack_master/stack_spec.rb index 0aa823c0..5d74b976 100644 --- a/spec/stack_master/stack_spec.rb +++ b/spec/stack_master/stack_spec.rb @@ -29,6 +29,10 @@ creation_time: Time.now, stack_status: 'UPDATE_COMPLETE', parameters: parameters, + tags: [ + { key: 'Environment', value: 'staging' }, + { key: 'Application', value: 'myapp' } + ], notification_arns: ['test_arn'], role_arn: 'test_service_role_arn' } @@ -62,6 +66,10 @@ it 'sets the stack policy' do expect(stack.stack_policy_body).to eq stack_policy_body end + + it 'sets tags' do + expect(stack.tags).to eq({ 'Environment' => 'staging', 'Application' => 'myapp' }) + end end context 'when the stack does not exist in AWS' do From 00a9825a07f63929b3f4c62380311a2c2255915a Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:37:25 +0700 Subject: [PATCH 2/3] Improve user feedback when CloudFormation reports no changes When CloudFormation returns 'no changes' errors during apply, provide a clearer explanation that while the template may have differences (e.g., whitespace, comments, formatting), no actual resource changes are needed and the stack is already in the desired state. This addresses the confusing scenario where users see a template diff but CloudFormation reports: 'The submitted information didn't contain changes. Submit different information to create a change set.' Changes: - Add user_friendly_changeset_error method to detect common 'no changes' error messages and provide helpful context - Update both create and update stack flows to use the improved message - Add comprehensive tests covering various CloudFormation error messages --- CHANGELOG.md | 2 ++ lib/stack_master/commands/apply.rb | 19 ++++++++++-- spec/stack_master/commands/apply_spec.rb | 37 ++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ccbe15..1d4407e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,14 @@ The format is based on [Keep a Changelog], and this project adheres to ### Changed +- 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]) [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 ## [2.17.1] - 2025-12-19 diff --git a/lib/stack_master/commands/apply.rb b/lib/stack_master/commands/apply.rb index 46c3438f..ad096952 100644 --- a/lib/stack_master/commands/apply.rb +++ b/lib/stack_master/commands/apply.rb @@ -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) @@ -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) @@ -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 diff --git a/spec/stack_master/commands/apply_spec.rb b/spec/stack_master/commands/apply_spec.rb index 9df35da9..1e1441f3 100644 --- a/spec/stack_master/commands/apply_spec.rb +++ b/spec/stack_master/commands/apply_spec.rb @@ -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 From 13683be7cf8c95a5a3a3c4a9b02fb9d4d41926f7 Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:34:35 +0700 Subject: [PATCH 3/3] CI: add Ruby 4.0 to the test matrix --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e169e39..0581c421 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4407e6..ab27cbc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,13 @@ The format is based on [Keep a Changelog], and this project adheres to - 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