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